{"repo": "airen3339/codewhale", "files": {"crates/tui/src/config.rs": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785745828.6734755, "n": 248801, "h": "63853eaba2c1de3f", "c": "//! Configuration loading and defaults for codewhale.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map mouse-wheel gestures to arrow keys. Default:\n /// `true` only when mouse capture is off; otherwise `false`.\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `codewhale` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`codewhale serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `codewhale auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n codewhale auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'codewhale auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'codewhale auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'codewhale auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'codewhale auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'codewhale auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: codewhale auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".codewhale\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\"Profile '{profile_name}' not found. Available profiles: {available}\")\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the codewhale config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the codewhale config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped codewhale key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"codewhale-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"codewhale v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `codewhale --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"codewhale-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "9cd4c082ac4e9eee87b026f50ac0408a92d0783a": {"t": 1785756830.219281, "n": 248801, "h": "63853eaba2c1de3f", "c": "//! Configuration loading and defaults for codewhale.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map mouse-wheel gestures to arrow keys. Default:\n /// `true` only when mouse capture is off; otherwise `false`.\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `codewhale` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`codewhale serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `codewhale auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n codewhale auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'codewhale auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'codewhale auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'codewhale auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'codewhale auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'codewhale auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: codewhale auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".codewhale\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\"Profile '{profile_name}' not found. Available profiles: {available}\")\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the codewhale config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the codewhale config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped codewhale key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"codewhale-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"codewhale v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `codewhale --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"codewhale-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "25ce4f59700bcac15ae0df913c9540db9b95a051": {"t": 1785756832.0023794, "n": 248803, "h": "66898e9c0704e917", "c": "//! Configuration loading and defaults for codewhale.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `codewhale` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`codewhale serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `codewhale auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n codewhale auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'codewhale auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'codewhale auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'codewhale auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'codewhale auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'codewhale auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: codewhale auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".codewhale\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\"Profile '{profile_name}' not found. Available profiles: {available}\")\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the codewhale config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the codewhale config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped codewhale key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"codewhale-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"codewhale v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `codewhale --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"codewhale-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "2947eff9d14987dd2ff2defa570ed7c5d7efedd1": {"t": 1785756834.1712515, "n": 248802, "h": "93dcf9e71296c032", "c": "//! Configuration loading and defaults for codewhale.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `codewhale` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`codewhale serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `codewhale auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n codewhale auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'codewhale auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'codewhale auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'codewhale auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'codewhale auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'codewhale auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: codewhale auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\"Profile '{profile_name}' not found. Available profiles: {available}\")\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the codewhale config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the codewhale config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{DEFAULT_TEXT_MODEL}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped codewhale key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"codewhale-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"codewhale v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `codewhale --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"codewhale-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "903e4537f4a5e915b01a75e0e189b256000c73d5": {"t": 1785756836.075571, "n": 248966, "h": "4c1c5ec9ecac9cfb", "c": "//! Configuration loading and defaults for codewhale.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `codewhale` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`codewhale serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `codewhale auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n codewhale auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'codewhale auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'codewhale auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'codewhale auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'codewhale auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'codewhale auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: codewhale auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"codewhale::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the codewhale config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the codewhale config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# codewhale Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped codewhale key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"codewhale-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"codewhale v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `codewhale --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"codewhale-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"codewhale-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "c6d73d98ded071c62c56448f9451dbc61ad4acef": {"t": 1785756837.9146345, "n": 248905, "h": "d7074484eedf49f4", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `codewhale_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = codewhale_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'deepseek auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`codewhale_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`codewhale_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = codewhale_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "ad122fd6f8afe0ec17605496492ffe94cbd23a10": {"t": 1785756840.0791106, "n": 248900, "h": "62f926500b347549", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\n/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.\n/// Matches the legacy hardcoded value so existing configs keep their old\n/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).\npub const DEFAULT_SUBAGENT_API_TIMEOUT_SECS: u64 = 120;\n/// Minimum accepted `[subagents] api_timeout_secs`. Anything lower (including\n/// `0`, which would otherwise produce an immediate timeout footgun) clamps\n/// up to this value before the runtime sees it.\npub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1;\n/// Maximum accepted `[subagents] api_timeout_secs` (30 minutes). The cap\n/// keeps a misconfigured per-step timeout from masking real model/network\n/// hangs forever.\npub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n /// Per-step DeepSeek API timeout for sub-agent requests, in seconds. The\n /// timeout wraps `client.create_message` so a stuck single step cannot\n /// pin the parent's parent-completion wakeup channel indefinitely.\n /// Defaults to `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) and is clamped\n /// to `MIN_SUBAGENT_API_TIMEOUT_SECS..=MAX_SUBAGENT_API_TIMEOUT_SECS`\n /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808).\n #[serde(default)]\n pub api_timeout_secs: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'deepseek auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Resolved per-step DeepSeek API timeout for sub-agents, in seconds.\n ///\n /// Reads `[subagents] api_timeout_secs` and clamps to\n /// `[MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS]`\n /// (1..=1800). `None` or `0` resolve to the legacy\n /// `DEFAULT_SUBAGENT_API_TIMEOUT_SECS` (120) so existing configs keep\n /// their old behavior; explicit `1` is honored, useful only in fast\n /// fail-fast tests, not production (#1806, #1808).\n #[must_use]\n pub fn subagent_api_timeout_secs(&self) -> u64 {\n let raw = self\n .subagents\n .as_ref()\n .and_then(|cfg| cfg.api_timeout_secs)\n .unwrap_or(DEFAULT_SUBAGENT_API_TIMEOUT_SECS);\n if raw == 0 {\n return DEFAULT_SUBAGENT_API_TIMEOUT_SECS;\n }\n raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn subagent_api_timeout_defaults_and_clamps() {\n assert_eq!(\n Config::default().subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let zero = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n zero.subagent_api_timeout_secs(),\n DEFAULT_SUBAGENT_API_TIMEOUT_SECS\n );\n\n let explicit_min = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MIN_SUBAGENT_API_TIMEOUT_SECS),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(explicit_min.subagent_api_timeout_secs(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n api_timeout_secs: Some(MAX_SUBAGENT_API_TIMEOUT_SECS + 60),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(\n high.subagent_api_timeout_secs(),\n MAX_SUBAGENT_API_TIMEOUT_SECS\n );\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "8597afc0763e3d345a295b4d62b6effda634d246": {"t": 1785756842.2035718, "n": 245482, "h": "93c7b1f300a43124", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_WANJIE_ARK_MODEL: &str = \"deepseek-reasoner\";\npub const DEFAULT_WANJIE_ARK_BASE_URL: &str = \"https://maas-openapi.wanjiedata.com/api/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"wanjie\" | \"wanjie-ark\" | \"wanjie_ark\" | \"ark-wanjie\" | \"ark_wanjie\" | \"wanjieark\"\n | \"wanjie-maas\" | \"wanjie_maas\" | \"wanjiemaas\" => Some(Self::WanjieArk),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::WanjieArk => \"wanjie-ark\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::WanjieArk => \"Wanjie Ark\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::WanjieArk,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n let is_reasoner = matches!(provider, ApiProvider::WanjieArk)\n && (model_lower.contains(\"reasoner\") || model_lower.contains(\"r1\"));\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash || is_reasoner;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub wanjie_ark: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::WanjieArk => &providers.wanjie_ark,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie-ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::WanjieArk => anyhow::bail!(\n \"Wanjie Ark API key not found. Run 'deepseek auth set --provider wanjie-ark', \\\n set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \\\n [providers.wanjie_ark] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::WanjieArk => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_BASE_URL\")\n .or_else(|_| std::env::var(\"WANJIE_BASE_URL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_BASE_URL\"))\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::WanjieArk)\n && let Ok(value) = std::env::var(\"WANJIE_ARK_MODEL\")\n .or_else(|_| std::env::var(\"WANJIE_MODEL\"))\n .or_else(|_| std::env::var(\"WANJIE_MAAS_MODEL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .wanjie_ark\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::WanjieArk => &mut providers.wanjie_ark,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::WanjieArk\n | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::WanjieArk => {\n std::env::var(\"WANJIE_ARK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::WanjieArk => \"WANJIE_ARK_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n if matches!(provider, ApiProvider::WanjieArk)\n && (std::env::var(\"WANJIE_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"WANJIE_MAAS_API_KEY\").is_ok_and(|k| !k.trim().is_empty()))\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::WanjieArk => \"providers.wanjie_ark\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::WanjieArk => \"wanjie_ark\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n wanjie_ark_api_key: Option,\n wanjie_api_key: Option,\n wanjie_maas_api_key: Option,\n wanjie_ark_base_url: Option,\n wanjie_base_url: Option,\n wanjie_maas_base_url: Option,\n wanjie_ark_model: Option,\n wanjie_model: Option,\n wanjie_maas_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let wanjie_ark_api_key_prev = env::var_os(\"WANJIE_ARK_API_KEY\");\n let wanjie_api_key_prev = env::var_os(\"WANJIE_API_KEY\");\n let wanjie_maas_api_key_prev = env::var_os(\"WANJIE_MAAS_API_KEY\");\n let wanjie_ark_base_url_prev = env::var_os(\"WANJIE_ARK_BASE_URL\");\n let wanjie_base_url_prev = env::var_os(\"WANJIE_BASE_URL\");\n let wanjie_maas_base_url_prev = env::var_os(\"WANJIE_MAAS_BASE_URL\");\n let wanjie_ark_model_prev = env::var_os(\"WANJIE_ARK_MODEL\");\n let wanjie_model_prev = env::var_os(\"WANJIE_MODEL\");\n let wanjie_maas_model_prev = env::var_os(\"WANJIE_MAAS_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"WANJIE_ARK_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n env::remove_var(\"WANJIE_MAAS_API_KEY\");\n env::remove_var(\"WANJIE_ARK_BASE_URL\");\n env::remove_var(\"WANJIE_BASE_URL\");\n env::remove_var(\"WANJIE_MAAS_BASE_URL\");\n env::remove_var(\"WANJIE_ARK_MODEL\");\n env::remove_var(\"WANJIE_MODEL\");\n env::remove_var(\"WANJIE_MAAS_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n wanjie_ark_api_key: wanjie_ark_api_key_prev,\n wanjie_api_key: wanjie_api_key_prev,\n wanjie_maas_api_key: wanjie_maas_api_key_prev,\n wanjie_ark_base_url: wanjie_ark_base_url_prev,\n wanjie_base_url: wanjie_base_url_prev,\n wanjie_maas_base_url: wanjie_maas_base_url_prev,\n wanjie_ark_model: wanjie_ark_model_prev,\n wanjie_model: wanjie_model_prev,\n wanjie_maas_model: wanjie_maas_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"WANJIE_ARK_API_KEY\", self.wanjie_ark_api_key.take());\n Self::restore_var(\"WANJIE_API_KEY\", self.wanjie_api_key.take());\n Self::restore_var(\"WANJIE_MAAS_API_KEY\", self.wanjie_maas_api_key.take());\n Self::restore_var(\"WANJIE_ARK_BASE_URL\", self.wanjie_ark_base_url.take());\n Self::restore_var(\"WANJIE_BASE_URL\", self.wanjie_base_url.take());\n Self::restore_var(\"WANJIE_MAAS_BASE_URL\", self.wanjie_maas_base_url.take());\n Self::restore_var(\"WANJIE_ARK_MODEL\", self.wanjie_ark_model.take());\n Self::restore_var(\"WANJIE_MODEL\", self.wanjie_model.take());\n Self::restore_var(\"WANJIE_MAAS_MODEL\", self.wanjie_maas_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"wanjie-ark\" => {\n providers.wanjie_ark.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"wanjie-ark\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"wanjie-ark\", \"WANJIE_ARK_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"wanjie-ark\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.default_model(), DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_WANJIE_ARK_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_env_overrides_provider_base_url_model_and_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ark-wanjie\");\n env::set_var(\"WANJIE_ARK_API_KEY\", \"wanjie-env-key\");\n env::set_var(\"WANJIE_ARK_BASE_URL\", \"https://wanjie.example/api/v1\");\n env::set_var(\"WANJIE_ARK_MODEL\", \"wanjie-model-id\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://wanjie.example/api/v1\");\n assert_eq!(config.default_model(), \"wanjie-model-id\");\n Ok(())\n }\n\n #[test]\n fn wanjie_ark_provider_accepts_custom_model_and_table_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-wanjie-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"wanjie-ark\"\n\n[providers.wanjie_ark]\napi_key = \"wanjie-table-key\"\nbase_url = \"https://maas-openapi.wanjiedata.com/api/v1\"\nmodel = \"account-model-id\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::WanjieArk);\n assert_eq!(config.deepseek_api_key()?, \"wanjie-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://maas-openapi.wanjiedata.com/api/v1\"\n );\n assert_eq!(config.default_model(), \"account-model-id\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n env::set_var(\"WANJIE_API_KEY\", \"wanjie-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"WANJIE_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.wanjie_ark.api_key = Some(\"file-wanjie\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::WanjieArk));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::WanjieArk, \"wanjie-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"wanjie_ark\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"wanjie-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::WanjieArk, DEFAULT_WANJIE_ARK_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "912da38ccae34a907c5234561f5f9b42ee1f8ada": {"t": 1785756844.1820226, "n": 234655, "h": "1dca17ecd240a536", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\n \"DeepSeek providers are handled in the if branch above (issue #1714)\"\n ),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "3adc05d627c58415aabe344b78610c68f8cbb592": {"t": 1785756846.1298094, "n": 234617, "h": "aeb02e41a49c1e44", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => unreachable!(\"DeepSeek providers are handled in the if branch above (issue #1714)\"),\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "b156d7da33cc9a16835aef3a1dfc1491090555d0": {"t": 1785756848.1927702, "n": 234602, "h": "9f65fb77317e4ef7", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n // An explicit provider-scoped model that is not a recognized\n // DeepSeek alias is a deliberate custom choice for a non-DeepSeek\n // provider (e.g. `MiniMax-M2.7` on an OpenAI-compatible endpoint).\n // It must pass through verbatim rather than fall back to a\n // DeepSeek/provider default (issue #1714).\n if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n let trimmed = model.trim();\n if !trimmed.is_empty() {\n return trimmed.to_string();\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the\n // provider-specific *_MODEL var. The legacy root `default_text_model`\n // is a DeepSeek-only slot (the validator rejects non-DeepSeek IDs\n // there). For a non-DeepSeek provider the explicit model must land in\n // the provider-scoped slot instead so the verbatim-passthrough path\n // honors it rather than falling back to a DeepSeek/provider default\n // (issue #1714). Mirror the OPENAI_MODEL branch above for every\n // non-DeepSeek provider.\n let provider = config.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n config.default_text_model = Some(value);\n } else {\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n entry.model = Some(value);\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n // Regression for issue #1714: `deepseek --provider openai --model\n // MiniMax-M2.7` forwards the choice via DEEPSEEK_MODEL (never\n // OPENAI_MODEL) and uses the DEFAULT base_url. The explicit custom model\n // must pass through verbatim instead of silently becoming a\n // DeepSeek/provider default.\n #[test]\n fn deepseek_model_env_passes_custom_model_through_for_non_deepseek_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-1714-passthrough-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n\n // (a) provider=openai + model=\"MiniMax-M2.7\" via env, NO OPENAI_MODEL,\n // DEFAULT base_url.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n // (b) a non-passthrough provider (novita) with an unknown custom model\n // and the DEFAULT base_url must also be preserved verbatim — never\n // rewritten to DEFAULT_NOVITA_MODEL.\n {\n let _guard = EnvGuard::new(&temp_root);\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n env::set_var(\"DEEPSEEK_MODEL\", \"MiniMax-M2.7\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n assert_ne!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.default_model(), \"MiniMax-M2.7\");\n }\n\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "4c32a316bea28bd5b63f39b0b0e8d7040af92ac5": {"t": 1785756850.2508438, "n": 229839, "h": "69e4fbecabdc34a3", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"cache prefix 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition for the always-on status line. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see a\n /// concise footer by default; diagnostic chips remain available via\n /// `/statusline` without crowding the main UI.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "eef16f45fdc8b4c062497f0a1646700f447a41aa": {"t": 1785756852.1823387, "n": 229787, "h": "3050c39dde08fff2", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\npub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-pro\", \"deepseek-v4-flash\"];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\nfn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4-pro\"\n | \"deepseek-v4pro\"\n | \"deepseek-ai/deepseek-v4-pro\"\n | \"deepseek-ai/deepseek-v4pro\"\n | \"deepseek/deepseek-v4-pro\"\n | \"deepseek/deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4-flash\"\n | \"deepseek-v4flash\"\n | \"deepseek-ai/deepseek-v4-flash\"\n | \"deepseek-ai/deepseek-v4flash\"\n | \"deepseek/deepseek-v4-flash\"\n | \"deepseek/deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a model selected through the TUI for the active provider.\n///\n/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed\n/// aliases are valid for some compatible backends, but sending them to\n/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for\n/// config/back-compat, and canonicalize only when the active provider is known.\n#[must_use]\npub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option {\n let normalized = normalize_model_name(model)?;\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(canonical) = canonical_official_deepseek_model_id(&normalized)\n {\n return Some(canonical.to_string());\n }\n if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) {\n return Some(model_for_provider(provider, canonical.to_string()));\n }\n Some(normalized)\n}\n\n#[must_use]\npub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> {\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),\n ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],\n ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],\n ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],\n ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],\n ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL],\n ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL],\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => {\n OFFICIAL_DEEPSEEK_MODELS.to_vec()\n }\n }\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"P 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek-ai/DeepSeek-V4-Pro\")\n .as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Deepseek, \"deepseek/deepseek-v4-flash\")\n .as_deref(),\n Some(\"deepseek-v4-flash\")\n );\n }\n\n #[test]\n fn normalize_model_name_for_provider_keeps_provider_specific_ids() {\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::NvidiaNim, \"deepseek-v4-pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n assert_eq!(\n normalize_model_name_for_provider(ApiProvider::Openrouter, \"deepseek-v4-flash\")\n .as_deref(),\n Some(DEFAULT_OPENROUTER_FLASH_MODEL)\n );\n }\n\n #[test]\n fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() {\n assert_eq!(\n model_completion_names_for_provider(ApiProvider::Deepseek),\n vec![\"deepseek-v4-pro\", \"deepseek-v4-flash\"]\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "a3f88bf6cf674c2089954dc88c2db7e24076f344": {"t": 1785756853.9755301, "n": 226018, "h": "38002b7e043afeb2", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// Bing HTML scraping. No API key needed.\n #[default]\n Bing,\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Bing => \"bing\",\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha`. Default: `bing`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for Bing or DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"P 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to Bing.\n /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those\n /// services instead; Tavily and Bocha also require an `api_key`.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n #[test]\n fn search_provider_defaults_to_bing() {\n assert_eq!(SearchProvider::default(), SearchProvider::Bing);\n }\n\n #[test]\n fn explicit_duckduckgo_search_provider_is_preserved() {\n let config: Config = toml::from_str(\n r#\"\n [search]\n provider = \"duckduckgo\"\n \"#,\n )\n .expect(\"search config\");\n\n assert_eq!(\n config.search.and_then(|search| search.provider),\n Some(SearchProvider::DuckDuckGo)\n );\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "d5c45d962d9017c2ba7f81094a97be1d7a35ccf2": {"t": 1785756856.5113583, "n": 225379, "h": "d4edb08acdf81c9b", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`,\n/// `PrefixStability`, `Cache`, `ContextPercent`, `GitBranch`,\n/// `LastToolElapsed`, `RateLimit`) likewise honour ordering inside their\n/// cluster. The split between left and right is deliberate — left holds steady\n/// identity (mode/model/cost), right holds transient signals — so we route\n/// each variant to the correct side rather than letting users reorder across\n/// the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Prefix stability (\"P 100%\").\n PrefixStability,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::PrefixStability => \"prefix_stability\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::PrefixStability => \"Prefix stability\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::PrefixStability => \"whether system/tools stayed cacheable\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::PrefixStability,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "e254780e486571d72b9ce84844d8acd40121d3fd": {"t": 1785756859.742634, "n": 225006, "h": "2c57d4b311304d30", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "9d1c1d1422db2a848d2ee0e2709a283db319050d": {"t": 1785756861.7544637, "n": 223836, "h": "6c922813557c4f86", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "485ba7bbd4937a7bab0ed41a0a5dd26c7c6b55b5": {"t": 1785756866.1989603, "n": 223791, "h": "e8abb28bc5d4d31e", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n if base_url_uses_local_host(&self.deepseek_base_url()) {\n return Ok(String::new());\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn base_url_uses_local_host(base_url: &str) -> bool {\n let Some(host) = base_url_host(base_url) else {\n return false;\n };\n let host = host.trim_matches(['[', ']']).to_ascii_lowercase();\n if matches!(host.as_str(), \"localhost\" | \"0.0.0.0\") {\n return true;\n }\n host.parse::()\n .is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())\n}\n\nfn base_url_host(base_url: &str) -> Option<&str> {\n let without_scheme = base_url\n .split_once(\"://\")\n .map_or(base_url, |(_, rest)| rest);\n let authority = without_scheme.split('/').next()?.rsplit('@').next()?;\n if let Some(rest) = authority.strip_prefix('[') {\n return rest.split_once(']').map(|(host, _)| host);\n }\n authority.split(':').next().filter(|host| !host.is_empty())\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if provider == config.api_provider() && base_url_uses_local_host(&config.deepseek_base_url()) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn loopback_deepseek_base_url_runs_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let config = Config {\n base_url: Some(\"http://127.0.0.1:8000/v1\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert!(has_api_key(&config));\n assert_eq!(config.deepseek_api_key()?, \"\");\n Ok(())\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "7e97d1442bf25ef743f2548051c35750036e0955": {"t": 1785756871.5258799, "n": 222334, "h": "9deae524282036f2", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: picks the best protocol for the current terminal\n /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Kitty notification protocol (OSC 99).\n Kitty,\n /// Ghostty notification protocol (OSC 777).\n Ghostty,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\nfn default_snapshot_max_workspace_gb() -> u64 {\n crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT / (1024 * 1024 * 1024)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n /// Maximum non-excluded workspace size (in GB) before the snapshot\n /// feature self-disables on first use. Set to `0` to disable the cap\n /// and snapshot regardless of size (the v0.8.31 behavior). The walk\n /// honors `.gitignore` and the snapshot module's built-in excludes\n /// (`node_modules/`, `target/`, ...) so the measured size reflects\n /// what would actually land in a snapshot commit.\n #[serde(default = \"default_snapshot_max_workspace_gb\")]\n pub max_workspace_gb: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n max_workspace_gb: default_snapshot_max_workspace_gb(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (). Requires api_key.\n Tavily,\n /// Bocha AI Search API (). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "3aaf0ad95e947a1691f99e303ceb4f5403acbe51": {"t": 1785756920.6221423, "n": 221573, "h": "92c3a606712feacc", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (https://tavily.com). Requires api_key.\n Tavily,\n /// Bocha AI Search API (https://bochaai.com). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n\n /// Vision model configuration for the `image_analyze` tool.\n #[serde(default)]\n pub vision_model: Option,\n}\n\n/// Vision model configuration for the `image_analyze` tool.\n/// Uses an OpenAI-compatible vision model API.\n#[derive(Debug, Clone, Deserialize)]\npub struct VisionModelConfig {\n /// Model identifier (e.g., \"gemini-3.1-flash-lite-preview\").\n pub model: String,\n /// API key for the vision model. Inherits from main config if not specified.\n #[serde(default)]\n pub api_key: Option,\n /// Base URL for the vision model API. Defaults to OpenAI.\n #[serde(default)]\n pub base_url: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n /// Return the configured vision model config, inheriting api_key from main config.\n #[must_use]\n pub fn vision_model_config(&self) -> Option {\n let mut config = self.vision_model.clone()?;\n if config.api_key.is_none() {\n config.api_key = self.api_key.clone();\n }\n Some(config)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n vision_model: override_cfg.vision_model.or(base.vision_model),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "8f33e4bd48dfd1d23e7bc90ccff9834773a320eb": {"t": 1785756922.4615285, "n": 220487, "h": "b51e8ba8da9921ff", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_ATLASCLOUD_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_ATLASCLOUD_BASE_URL: &str = \"https://api.atlascloud.ai/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"atlascloud\" | \"atlas-cloud\" | \"atlas_cloud\" | \"atlas\" => Some(Self::Atlascloud),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Atlascloud => \"atlascloud\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Atlascloud => \"AtlasCloud\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Atlascloud,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (https://tavily.com). Requires api_key.\n Tavily,\n /// Bocha AI Search API (https://bochaai.com). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub atlascloud: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Atlascloud => &providers.atlascloud,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Atlascloud\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Atlascloud => anyhow::bail!(\n \"AtlasCloud API key not found. Run 'deepseek auth set --provider atlascloud', \\\n set ATLASCLOUD_API_KEY, or add [providers.atlascloud] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n ApiProvider::Atlascloud => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .atlascloud\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Atlascloud => &mut providers.atlascloud,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Atlascloud)\n && let Ok(value) = std::env::var(\"ATLASCLOUD_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(\n provider,\n ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama\n )\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Atlascloud => {\n std::env::var(\"ATLASCLOUD_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Atlascloud => \"ATLASCLOUD_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Atlascloud => \"providers.atlascloud\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Atlascloud => \"atlascloud\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n atlascloud_api_key: Option,\n atlascloud_base_url: Option,\n atlascloud_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let atlascloud_api_key_prev = env::var_os(\"ATLASCLOUD_API_KEY\");\n let atlascloud_base_url_prev = env::var_os(\"ATLASCLOUD_BASE_URL\");\n let atlascloud_model_prev = env::var_os(\"ATLASCLOUD_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"ATLASCLOUD_API_KEY\");\n env::remove_var(\"ATLASCLOUD_BASE_URL\");\n env::remove_var(\"ATLASCLOUD_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n atlascloud_api_key: atlascloud_api_key_prev,\n atlascloud_base_url: atlascloud_base_url_prev,\n atlascloud_model: atlascloud_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"ATLASCLOUD_API_KEY\", self.atlascloud_api_key.take());\n Self::restore_var(\"ATLASCLOUD_BASE_URL\", self.atlascloud_base_url.take());\n Self::restore_var(\"ATLASCLOUD_MODEL\", self.atlascloud_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_provider_uses_documented_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"atlascloud\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.default_model(), DEFAULT_ATLASCLOUD_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_ATLASCLOUD_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn atlascloud_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-atlascloud-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"atlascloud\");\n env::set_var(\"ATLASCLOUD_API_KEY\", \"atlascloud-env-key\");\n env::set_var(\"ATLASCLOUD_BASE_URL\", \"https://api.atlascloud.ai/v1\");\n env::set_var(\"ATLASCLOUD_MODEL\", \"deepseek-ai/deepseek-v4-flash\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Atlascloud);\n assert_eq!(config.deepseek_api_key()?, \"atlascloud-env-key\");\n assert_eq!(config.deepseek_base_url(), \"https://api.atlascloud.ai/v1\");\n assert_eq!(config.default_model(), \"deepseek-ai/deepseek-v4-flash\");\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_atlascloud_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Atlascloud, \"deepseek-ai/deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "40df46c73d1f5d0bc6814a9526c94625e66b26e3": {"t": 1785756924.5225227, "n": 214811, "h": "d22d9c8bafa69c31", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// Search provider enumeration — selects which backend `web_search` uses.\n#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum SearchProvider {\n /// DuckDuckGo HTML scraping with Bing fallback. No API key needed.\n #[default]\n #[serde(alias = \"duckduckgo\")]\n DuckDuckGo,\n /// Tavily AI Search API (https://tavily.com). Requires api_key.\n Tavily,\n /// Bocha AI Search API (https://bochaai.com). Requires api_key.\n Bocha,\n}\n\nimpl SearchProvider {\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::DuckDuckGo => \"duckduckgo\",\n Self::Tavily => \"tavily\",\n Self::Bocha => \"bocha\",\n }\n }\n}\n\n/// Web search provider configuration (`[search]` table in config.toml).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SearchConfig {\n /// Search provider: `duckduckgo` | `tavily` | `bocha`. Default: `duckduckgo`.\n #[serde(default)]\n pub provider: Option,\n /// API key for Tavily or Bocha. Not required for DuckDuckGo.\n #[serde(default)]\n pub api_key: Option,\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// Web search provider configuration. When absent, defaults to DuckDuckGo\n /// with Bing fallback. Set `provider` to `tavily` or `bocha` and provide\n /// an `api_key` to use those services instead.\n #[serde(default)]\n pub search: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n search: override_cfg.search.or(base.search),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "62a893d9c2c2e32976879c41731f61621a55059b": {"t": 1785756926.3178625, "n": 213315, "h": "7b4b974f53f7ce62", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n // Custom base URL preserves the user-specified model name; normalisation\n // is skipped because the gateway expects the model name as-provided.\n assert_eq!(config.default_model(), \"deepseek-v4-pro\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "60347b89408ff7d550f32d3f77265cc149e6a04f": {"t": 1785756929.179701, "n": 213217, "h": "c94154a501ce6101", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux\n /// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux\n /// it falls back to BEL, and on Windows it falls back to `Off` so the\n /// post-turn notification doesn't ring the system error chime (#583).\n /// Use `method = \"osc9\"` explicitly when your terminal is OSC-9 capable\n /// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "c2d13c21bf6821a44ff7c5ee6ad21fe5b0ba6919": {"t": 1785756931.063098, "n": 213013, "h": "8a0c05d9e97ff3b7", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on\n /// macOS / Linux it falls back to BEL, and on Windows it falls\n /// back to `Off` so the post-turn notification doesn't ring the\n /// system error chime (#583).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n unsafe { std::env::set_var(\"DEEPSEEK_SECRET_BACKEND\", \"local\") };\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "5eb961760ec7ff28a40d09fa9faed84ee8c5c451": {"t": 1785756932.8596916, "n": 214035, "h": "d5c423b6a5aa051f", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on\n /// macOS / Linux it falls back to BEL, and on Windows it falls\n /// back to `Off` so the post-turn notification doesn't ring the\n /// system error chime (#583).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// `[auto]` table — knobs for the `--model auto` / `/model auto` router.\n///\n/// `cost_saving` (#1207): when `true`, the auto-mode router prefers\n/// `deepseek-v4-flash` for ambiguous requests, only escalating to\n/// `deepseek-v4-pro` when the task clearly benefits from deeper reasoning.\n/// Default is `false` (balanced — match the existing routing voice).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct AutoConfig {\n #[serde(default)]\n pub cost_saving: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Tunables for `--model auto` (#1207). When absent, the auto router\n /// keeps its existing balanced behaviour.\n #[serde(default)]\n pub auto: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Return `true` if the `[auto] cost_saving = true` opt-in is set\n /// (#1207). When true, the auto-mode router biases toward\n /// `deepseek-v4-flash` for ambiguous requests instead of escalating to\n /// `deepseek-v4-pro`. Default: `false` (balanced behaviour).\n #[must_use]\n pub fn auto_cost_saving(&self) -> bool {\n self.auto\n .as_ref()\n .and_then(|a| a.cost_saving)\n .unwrap_or(false)\n }\n\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n auto: override_cfg.auto.or(base.auto),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "6c25a18b423b789c97d8a0408eee7134b7a55b9d": {"t": 1785756934.7067275, "n": 212865, "h": "5c19d2ef7915ee14", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on\n /// macOS / Linux it falls back to BEL, and on Windows it falls\n /// back to `Off` so the post-turn notification doesn't ring the\n /// system error chime (#583).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\npub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "5065092a115c13da4d06c49532a2c01ab748ecaa": {"t": 1785756936.4728944, "n": 212854, "h": "7338badab7882e56", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on\n /// macOS / Linux it falls back to BEL, and on Windows it falls\n /// back to `Off` so the post-turn notification doesn't ring the\n /// system error chime (#583).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\nfn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}, "fddda04fcaecba350cee0b207bb6e61da01e80af": {"t": 1785756938.4162087, "n": 212753, "h": "412f0a74fba781fc", "c": "//! Configuration loading and defaults for DeepSeek TUI.\n\nuse std::collections::HashMap;\nuse std::fmt::Write;\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write as _;\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\n#[cfg(unix)]\nuse std::os::unix::fs::{OpenOptionsExt, PermissionsExt};\n\nuse crate::audit::log_sensitive_event;\nuse crate::features::{Features, FeaturesToml, is_known_feature_key};\nuse crate::hooks::HooksConfig;\n\npub const DEFAULT_MAX_SUBAGENTS: usize = 10;\npub const MAX_SUBAGENTS: usize = 20;\npub const DEFAULT_TEXT_MODEL: &str = \"deepseek-v4-pro\";\npub const DEFAULT_DEEPSEEK_BASE_URL: &str = \"https://api.deepseek.com/beta\";\npub const DEFAULT_NVIDIA_NIM_MODEL: &str = \"deepseek-ai/deepseek-v4-pro\";\npub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = \"deepseek-ai/deepseek-v4-flash\";\npub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = \"https://integrate.api.nvidia.com/v1\";\npub const DEFAULT_OPENAI_MODEL: &str = \"gpt-4.1\";\npub const DEFAULT_OPENAI_BASE_URL: &str = \"https://api.openai.com/v1\";\npub const DEFAULT_OPENROUTER_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_OPENROUTER_BASE_URL: &str = \"https://openrouter.ai/api/v1\";\npub const DEFAULT_NOVITA_MODEL: &str = \"deepseek/deepseek-v4-pro\";\npub const DEFAULT_NOVITA_FLASH_MODEL: &str = \"deepseek/deepseek-v4-flash\";\npub const DEFAULT_NOVITA_BASE_URL: &str = \"https://api.novita.ai/v1\";\npub const DEFAULT_FIREWORKS_MODEL: &str = \"accounts/fireworks/models/deepseek-v4-pro\";\npub const DEFAULT_FIREWORKS_BASE_URL: &str = \"https://api.fireworks.ai/inference/v1\";\npub const DEFAULT_SGLANG_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_SGLANG_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_SGLANG_BASE_URL: &str = \"http://localhost:30000/v1\";\npub const DEFAULT_VLLM_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Pro\";\npub const DEFAULT_VLLM_FLASH_MODEL: &str = \"deepseek-ai/DeepSeek-V4-Flash\";\npub const DEFAULT_VLLM_BASE_URL: &str = \"http://localhost:8000/v1\";\npub const DEFAULT_OLLAMA_MODEL: &str = \"deepseek-coder:1.3b\";\npub const DEFAULT_OLLAMA_BASE_URL: &str = \"http://localhost:11434/v1\";\n/// Legacy `deepseek-cn` provider alias.\n///\n/// DeepSeek's official API host is the same worldwide. Keep this alias for\n/// old configs, but route it through the normal beta-enabled DeepSeek default.\n/// Legacy typo hostname `api.deepseeki.com` remains recognized in URL\n/// heuristics for backward compatibility.\npub const DEFAULT_DEEPSEEKCN_BASE_URL: &str = DEFAULT_DEEPSEEK_BASE_URL;\nconst API_KEYRING_SENTINEL: &str = \"__KEYRING__\";\npub const COMMON_DEEPSEEK_MODELS: &[&str] = &[\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-ai/deepseek-v4-pro\",\n \"deepseek-ai/deepseek-v4-flash\",\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n];\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiProvider {\n Deepseek,\n DeepseekCN,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl ApiProvider {\n #[must_use]\n pub fn parse(value: &str) -> Option {\n match value.trim().to_ascii_lowercase().as_str() {\n \"deepseek\" | \"deep-seek\" => Some(Self::Deepseek),\n \"deepseek-cn\" | \"deepseek_china\" | \"deepseekcn\" | \"deepseek-china\" => {\n Some(Self::DeepseekCN)\n }\n \"nvidia\" | \"nvidia-nim\" | \"nvidia_nim\" | \"nim\" => Some(Self::NvidiaNim),\n \"openai\" | \"open-ai\" => Some(Self::Openai),\n \"openrouter\" | \"open_router\" => Some(Self::Openrouter),\n \"novita\" => Some(Self::Novita),\n \"fireworks\" | \"fireworks-ai\" => Some(Self::Fireworks),\n \"sglang\" | \"sg-lang\" => Some(Self::Sglang),\n \"vllm\" | \"v-llm\" => Some(Self::Vllm),\n \"ollama\" | \"ollama-local\" => Some(Self::Ollama),\n _ => None,\n }\n }\n\n #[must_use]\n pub fn as_str(self) -> &'static str {\n match self {\n Self::Deepseek => \"deepseek\",\n Self::DeepseekCN => \"deepseek-cn\",\n Self::NvidiaNim => \"nvidia-nim\",\n Self::Openai => \"openai\",\n Self::Openrouter => \"openrouter\",\n Self::Novita => \"novita\",\n Self::Fireworks => \"fireworks\",\n Self::Sglang => \"sglang\",\n Self::Vllm => \"vllm\",\n Self::Ollama => \"ollama\",\n }\n }\n\n /// Human-friendly label for picker UIs / status chips.\n #[must_use]\n pub fn display_name(self) -> &'static str {\n match self {\n Self::Deepseek => \"DeepSeek\",\n Self::DeepseekCN => \"DeepSeek (legacy alias)\",\n Self::NvidiaNim => \"NVIDIA NIM\",\n Self::Openai => \"OpenAI-compatible\",\n Self::Openrouter => \"OpenRouter\",\n Self::Novita => \"Novita AI\",\n Self::Fireworks => \"Fireworks AI\",\n Self::Sglang => \"SGLang\",\n Self::Vllm => \"vLLM\",\n Self::Ollama => \"Ollama\",\n }\n }\n\n /// All providers, in the order shown in the picker.\n #[must_use]\n pub fn all() -> &'static [Self] {\n &[\n Self::Deepseek,\n Self::NvidiaNim,\n Self::Openai,\n Self::Openrouter,\n Self::Novita,\n Self::Fireworks,\n Self::Sglang,\n Self::Vllm,\n Self::Ollama,\n ]\n }\n}\n\n// ============================================================================\n// Provider Capability Matrix\n// ============================================================================\n\n/// Known capabilities for a provider + resolved-model combination.\n///\n/// Returned by [`provider_capability`] to describe what a given provider\n/// supports for the resolved model string. All fields are derived from\n/// static knowledge (release docs, API guides) rather than live API probes.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]\npub struct ProviderCapability {\n /// Canonical provider identifier.\n pub provider: ApiProvider,\n /// Resolved model identifier that will be sent in the API payload.\n pub resolved_model: String,\n /// Context window in tokens (the maximum input the model can accept).\n pub context_window: u32,\n /// Official maximum output tokens for this combo.\n ///\n /// This is model metadata for diagnostics and CI policy. Normal turns use\n /// a separate, more conservative request cap in the engine.\n pub max_output: u32,\n /// Whether the provider+model supports thinking/reasoning mode.\n pub thinking_supported: bool,\n /// Whether the provider returns prompt-cache telemetry fields.\n pub cache_telemetry_supported: bool,\n /// Which request-payload dialect the provider uses.\n pub request_payload_mode: RequestPayloadMode,\n /// Deprecation metadata for compatibility aliases that are still accepted.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub alias_deprecation: Option,\n}\n\npub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = \"2026-07-24\";\npub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = \"2026-07-24T15:59:00Z\";\npub const DEEPSEEK_ALIAS_REPLACEMENT: &str = \"deepseek-v4-flash\";\n\n/// Upstream retirement metadata for a model alias that remains compatible.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub struct ModelAliasDeprecation {\n pub alias: String,\n pub replacement: String,\n pub retirement_date: String,\n pub retirement_utc: String,\n pub notice: String,\n}\n\n/// Which request-payload dialect the provider speaks.\n#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]\npub enum RequestPayloadMode {\n /// Standard OpenAI-compatible `/v1/chat/completions` payload.\n ChatCompletions,\n}\n\n/// Resolve the provider capability for a given [`ApiProvider`] and resolved\n/// model string.\n///\n/// The `resolved_model` should be the final model identifier that will appear\n/// in the API payload (after normalization / provider-specific mapping).\n#[must_use]\npub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability {\n if matches!(provider, ApiProvider::Openai) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n if matches!(provider, ApiProvider::Ollama) {\n return ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window: 8192,\n max_output: 4096,\n thinking_supported: false,\n cache_telemetry_supported: false,\n request_payload_mode: RequestPayloadMode::ChatCompletions,\n alias_deprecation: None,\n };\n }\n\n let model_lower = resolved_model.to_ascii_lowercase();\n let alias_deprecation = if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n deepseek_alias_deprecation(&model_lower)\n } else {\n None\n };\n let is_v4_pro = model_lower.contains(\"v4-pro\") || model_lower == \"deepseek-v4pro\";\n let is_v4_flash = model_lower.contains(\"v4-flash\")\n || model_lower == \"deepseek-v4flash\"\n || model_lower == \"deepseek-v4\"\n || alias_deprecation.is_some();\n\n // Context window: V4-class models get 1M, everything else falls through\n // to the model's own lookup or a default.\n let context_window = if is_v4_pro || is_v4_flash {\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n } else {\n crate::models::context_window_for_model(resolved_model)\n .unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)\n };\n\n // Max output tokens: official DeepSeek V4 API metadata lists 384K;\n // runtime request caps remain separate and more conservative.\n let max_output = if is_v4_pro || is_v4_flash {\n 384_000\n } else {\n 4096\n };\n\n // Thinking support: V4 models support thinking on all providers, but\n // only when the model name matches the V4 family.\n let thinking_supported = is_v4_pro || is_v4_flash;\n\n // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.\n let cache_telemetry_supported = matches!(\n provider,\n ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim\n );\n\n // Request payload mode: all current providers use chat completions.\n let request_payload_mode = RequestPayloadMode::ChatCompletions;\n\n ProviderCapability {\n provider,\n resolved_model: resolved_model.to_string(),\n context_window,\n max_output,\n thinking_supported,\n cache_telemetry_supported,\n request_payload_mode,\n alias_deprecation,\n }\n}\n\nfn deepseek_alias_deprecation(model_lower: &str) -> Option {\n match model_lower {\n \"deepseek-chat\" | \"deepseek-reasoner\" => Some(ModelAliasDeprecation {\n alias: model_lower.to_string(),\n replacement: DEEPSEEK_ALIAS_REPLACEMENT.to_string(),\n retirement_date: DEEPSEEK_ALIAS_RETIREMENT_DATE.to_string(),\n retirement_utc: DEEPSEEK_ALIAS_RETIREMENT_UTC.to_string(),\n notice: format!(\n \"{model_lower} is a compatibility alias for {DEEPSEEK_ALIAS_REPLACEMENT} and is scheduled to retire on {DEEPSEEK_ALIAS_RETIREMENT_DATE}.\"\n ),\n }),\n _ => None,\n }\n}\n\n/// Canonicalize compact DeepSeek model aliases to stable IDs.\n///\n/// Already-valid model IDs pass through unchanged. Only the compact\n/// `v4pro`/`v4flash` spellings are rewritten to their hyphenated forms.\n#[must_use]\npub fn canonical_model_name(model: &str) -> Option<&'static str> {\n match model.trim().to_ascii_lowercase().as_str() {\n \"deepseek-v4pro\" => Some(\"deepseek-v4-pro\"),\n \"deepseek-v4flash\" => Some(\"deepseek-v4-flash\"),\n _ => None,\n }\n}\n\n/// Normalize a configured/runtime model name.\n///\n/// Trims whitespace, preserves caller-provided case for already-valid model\n/// IDs, and only canonicalizes compact aliases like `deepseek-v4pro`.\n/// Non-DeepSeek or malformed names return `None`; DeepSeek's `/v1/models`\n/// endpoint is the authority on valid model IDs.\n#[must_use]\npub fn normalize_model_name(model: &str) -> Option {\n let trimmed = model.trim();\n if trimmed.is_empty() {\n return None;\n }\n if let Some(canonical) = canonical_model_name(trimmed) {\n return Some(canonical.to_string());\n }\n\n let normalized = trimmed.to_ascii_lowercase();\n if !normalized.starts_with(\"deepseek\") && !normalized.contains(\"/deepseek\") {\n return None;\n }\n\n if trimmed\n .chars()\n .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))\n {\n return Some(trimmed.to_string());\n }\n\n None\n}\n\n// === Types ===\n\n/// Raw retry configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize)]\npub struct RetryConfig {\n pub enabled: Option,\n pub max_retries: Option,\n pub initial_delay: Option,\n pub max_delay: Option,\n pub exponential_base: Option,\n}\n\n/// UI configuration loaded from config files.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct TuiConfig {\n pub alternate_screen: Option,\n pub mouse_capture: Option,\n /// Timeout for startup terminal mode/probe calls in milliseconds.\n /// Defaults to 500ms when omitted.\n pub terminal_probe_timeout_ms: Option,\n /// Ordered list of footer items the user wants visible. `None` (the field\n /// missing from `config.toml`) means \"use the built-in default order\"; an\n /// empty `Some(vec![])` means \"show nothing in the footer\".\n ///\n /// Edited interactively via `/statusline`; persisted to `tui.status_items`\n /// in `~/.deepseek/config.toml`.\n pub status_items: Option>,\n /// Emit OSC 8 hyperlink escape sequences around URLs in the transcript so\n /// supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty,\n /// WezTerm, Alacritty, recent gnome-terminal/konsole) make them\n /// Cmd+click-openable. Terminals without OSC 8 support render the plain\n /// label and ignore the escape. Defaults to `true`; set `false` for\n /// terminals that misrender the sequence.\n pub osc8_links: Option,\n /// High-level notification trigger condition. When set, overrides the\n /// `[notifications].threshold_secs` gate from the lower-level\n /// `[notifications]` block:\n ///\n /// - `Always` — fire a turn-completion notification on every successful\n /// turn regardless of duration. The configured `[notifications].method`\n /// and `include_summary` flag are still respected.\n /// - `Never` — suppress all turn-completion notifications.\n /// - Unset (default) — fall back to the `[notifications]` defaults.\n pub notification_condition: Option,\n /// When `true`, plain Up/Down on an empty composer scroll the\n /// transcript instead of recalling input history. Useful for\n /// terminals that map trackpad gestures to arrow keys. Default:\n /// `false` (plain arrows always navigate input history, #1117).\n #[serde(default)]\n pub composer_arrows_scroll: Option,\n}\n\n/// High-level notification trigger override. See\n/// [`TuiConfig::notification_condition`].\n#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum NotificationCondition {\n /// Notify on every successful turn (no duration threshold).\n Always,\n /// Suppress notifications entirely.\n Never,\n}\n\n/// Notification delivery method (mirrors `tui::notifications::Method`).\n#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\npub enum NotificationMethod {\n /// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL on\n /// macOS / Linux otherwise; on Windows the fallback is `Off`\n /// because BEL maps to the system error chime there (#583).\n #[default]\n Auto,\n /// OSC 9 escape.\n Osc9,\n /// Plain BEL character.\n Bel,\n /// Disable notifications.\n Off,\n}\n\nfn default_threshold_secs() -> u64 {\n 30\n}\n\n/// Desktop-notification configuration (OSC 9 / BEL on turn completion).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct NotificationsConfig {\n /// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.\n /// `auto` resolves to OSC 9 in iTerm.app / Ghostty / WezTerm; on\n /// macOS / Linux it falls back to BEL, and on Windows it falls\n /// back to `Off` so the post-turn notification doesn't ring the\n /// system error chime (#583).\n #[serde(default)]\n pub method: NotificationMethod,\n /// Only notify when the turn took at least this many seconds. Default: 30.\n #[serde(default = \"default_threshold_secs\")]\n pub threshold_secs: u64,\n /// Include a short summary (elapsed time + cost) in the notification body.\n /// Default: `false`.\n #[serde(default)]\n pub include_summary: bool,\n}\n\nfn default_snapshots_enabled() -> bool {\n true\n}\n\nfn default_snapshot_max_age_days() -> u64 {\n crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)\n}\n\n/// Workspace side-git snapshot configuration (#137).\n#[derive(Debug, Clone, Deserialize)]\npub struct SnapshotsConfig {\n /// Snapshot the workspace before and after each interactive agent turn.\n #[serde(default = \"default_snapshots_enabled\")]\n pub enabled: bool,\n /// Prune side-git snapshots older than this many days at session boot.\n #[serde(default = \"default_snapshot_max_age_days\")]\n pub max_age_days: u64,\n}\n\nimpl Default for SnapshotsConfig {\n fn default() -> Self {\n Self {\n enabled: default_snapshots_enabled(),\n max_age_days: default_snapshot_max_age_days(),\n }\n }\n}\n\n/// User-level memory configuration (#489).\n///\n/// Default is opt-in: when this table is absent or `enabled = false`, the\n/// memory file is neither read nor written, and `# foo` quick-adds in the\n/// composer fall through to the normal turn-submission path.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct MemoryConfig {\n /// When `true`, load the user memory file at `Config::memory_path()`\n /// into the system prompt as a `` block, and intercept\n /// `# foo` typed in the composer to append to that file. Default `false`.\n #[serde(default)]\n pub enabled: Option,\n}\n\nimpl SnapshotsConfig {\n #[must_use]\n pub fn max_age(&self) -> std::time::Duration {\n std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))\n }\n}\n\n/// One configurable footer item.\n///\n/// Order in the user's `Vec` is preserved: items in the left\n/// cluster (`Mode`, `Model`, `Cost`, `Status`) render in the order given;\n/// right-cluster chips (`Coherence`, `Agents`, `ReasoningReplay`, `Cache`,\n/// `ContextPercent`, `GitBranch`, `LastToolElapsed`, `RateLimit`) likewise\n/// honour ordering inside their cluster. The split between left and right is\n/// deliberate — left holds steady identity (mode/model/cost), right holds\n/// transient signals — so we route each variant to the correct side rather\n/// than letting users reorder across the spacer.\n///\n/// Variants without a current data source (`RateLimit`, `LastToolElapsed`)\n/// are intentionally exposed today so the picker is forward-compatible; they\n/// render empty until the supporting fields land. Empty spans don't take\n/// up footer width, so the user sees no visual artifact.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]\n#[serde(rename_all = \"snake_case\")]\npub enum StatusItem {\n /// \"agent\" / \"yolo\" / \"plan\" chip.\n Mode,\n /// Model identifier (e.g. `deepseek-v4-pro`).\n Model,\n /// Session cost in the configured display currency.\n Cost,\n /// Activity label: \"ready\" / \"draft\" / \"working\".\n Status,\n /// Coherence intervention label: \"refreshing context\" / \"verifying\" / \"resetting plan\".\n Coherence,\n /// Sub-agent count chip (\"3 agents\").\n Agents,\n /// Reasoning-replay token count (\"rsn 12.3k\").\n ReasoningReplay,\n /// Cache hit rate (\"cache 73%\").\n Cache,\n /// Context-window utilisation percent (\"48%\").\n ContextPercent,\n /// Current git branch name.\n GitBranch,\n /// Elapsed time of the most recent tool call (placeholder until wired).\n LastToolElapsed,\n /// Remaining rate-limit budget (placeholder until wired).\n RateLimit,\n}\n\nimpl StatusItem {\n /// Default footer composition matching v0.6.6 behaviour exactly. Used when\n /// `tui.status_items` is missing from `config.toml` so upgraders see the\n /// same footer they had before.\n #[must_use]\n pub fn default_footer() -> Vec {\n vec![\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n ]\n }\n\n /// Stable canonical name used in TOML and the picker label.\n #[must_use]\n pub fn key(self) -> &'static str {\n match self {\n StatusItem::Mode => \"mode\",\n StatusItem::Model => \"model\",\n StatusItem::Cost => \"cost\",\n StatusItem::Status => \"status\",\n StatusItem::Coherence => \"coherence\",\n StatusItem::Agents => \"agents\",\n StatusItem::ReasoningReplay => \"reasoning_replay\",\n StatusItem::Cache => \"cache\",\n StatusItem::ContextPercent => \"context_percent\",\n StatusItem::GitBranch => \"git_branch\",\n StatusItem::LastToolElapsed => \"last_tool_elapsed\",\n StatusItem::RateLimit => \"rate_limit\",\n }\n }\n\n /// Human-readable label for the picker.\n #[must_use]\n pub fn label(self) -> &'static str {\n match self {\n StatusItem::Mode => \"Mode\",\n StatusItem::Model => \"Model\",\n StatusItem::Cost => \"Session cost\",\n StatusItem::Status => \"Activity (ready/draft/working)\",\n StatusItem::Coherence => \"Coherence interventions\",\n StatusItem::Agents => \"Sub-agents in flight\",\n StatusItem::ReasoningReplay => \"Reasoning replay tokens\",\n StatusItem::Cache => \"Prompt cache hit rate\",\n StatusItem::ContextPercent => \"Context window %\",\n StatusItem::GitBranch => \"Git branch\",\n StatusItem::LastToolElapsed => \"Last tool elapsed\",\n StatusItem::RateLimit => \"Rate-limit remaining\",\n }\n }\n\n /// One-line hint shown beside the label so the user knows what each item\n /// surfaces without having to toggle it on first.\n #[must_use]\n pub fn hint(self) -> &'static str {\n match self {\n StatusItem::Mode => \"agent · yolo · plan\",\n StatusItem::Model => \"the model id you'll send to\",\n StatusItem::Cost => \"running total for this session\",\n StatusItem::Status => \"what the agent is doing right now\",\n StatusItem::Coherence => \"shown only when the engine intervenes\",\n StatusItem::Agents => \"agents or RLM work in progress\",\n StatusItem::ReasoningReplay => \"thinking tokens replayed each turn\",\n StatusItem::Cache => \"% of prompt served from cache\",\n StatusItem::ContextPercent => \"tokens used / model context window\",\n StatusItem::GitBranch => \"current workspace branch\",\n StatusItem::LastToolElapsed => \"ms of the most recent tool call (placeholder)\",\n StatusItem::RateLimit => \"remaining requests in the budget (placeholder)\",\n }\n }\n\n /// Every variant in display order — used by the picker to enumerate rows.\n #[must_use]\n pub fn all() -> &'static [StatusItem] {\n &[\n StatusItem::Mode,\n StatusItem::Model,\n StatusItem::Cost,\n StatusItem::Status,\n StatusItem::Coherence,\n StatusItem::Agents,\n StatusItem::ReasoningReplay,\n StatusItem::Cache,\n StatusItem::ContextPercent,\n StatusItem::GitBranch,\n StatusItem::LastToolElapsed,\n StatusItem::RateLimit,\n ]\n }\n\n /// Items that belong in the footer's left cluster (steady identity).\n #[must_use]\n pub fn is_left_cluster(self) -> bool {\n matches!(\n self,\n StatusItem::Mode | StatusItem::Model | StatusItem::Cost | StatusItem::Status\n )\n }\n}\n\n/// Resolved retry policy with defaults applied.\n#[derive(Debug, Clone)]\npub struct RetryPolicy {\n pub enabled: bool,\n pub max_retries: u32,\n pub initial_delay: f64,\n pub max_delay: f64,\n pub exponential_base: f64,\n}\n\n/// Capacity-controller config loaded from config files/environment.\n#[derive(Debug, Clone, Deserialize)]\npub struct CapacityConfig {\n pub enabled: Option,\n pub low_risk_max: Option,\n pub medium_risk_max: Option,\n pub severe_min_slack: Option,\n pub severe_violation_ratio: Option,\n pub refresh_cooldown_turns: Option,\n pub replan_cooldown_turns: Option,\n pub max_replay_per_turn: Option,\n pub min_turns_before_guardrail: Option,\n pub profile_window: Option,\n pub deepseek_v3_2_chat_prior: Option,\n pub deepseek_v3_2_reasoner_prior: Option,\n pub deepseek_v4_pro_prior: Option,\n pub deepseek_v4_flash_prior: Option,\n pub fallback_default_prior: Option,\n}\n\nimpl RetryPolicy {\n /// Compute the backoff delay for a retry attempt.\n #[must_use]\n #[allow(dead_code)] // used by runtime_api; will be wired into client retry loop\n pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {\n let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);\n let delay = self.initial_delay * self.exponential_base.powi(exponent);\n let delay = delay.min(self.max_delay);\n // Clamp to a sane range to guard against NaN/negative from misconfigured values\n let delay = delay.clamp(0.0, 300.0);\n std::time::Duration::from_secs_f64(delay)\n }\n}\n\n/// Context management configuration (append-only layered context with Flash seams).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ContextConfig {\n /// Master enable for layered context management. Default: false while\n /// v0.7.5 audits V4 prefix-cache behavior.\n #[serde(default)]\n pub enabled: Option,\n /// Include a deterministic project context pack in the stable prompt\n /// prefix. Default: true; set `[context] project_pack = false` to disable.\n #[serde(default)]\n pub project_pack: Option,\n /// Verbatim window: last N turns never summarized. Default: 16.\n #[serde(default)]\n pub verbatim_window_turns: Option,\n /// Soft seam thresholds based on the active request input estimate.\n #[serde(default)]\n pub l1_threshold: Option,\n #[serde(default)]\n pub l2_threshold: Option,\n #[serde(default)]\n pub l3_threshold: Option,\n /// Hard cycle boundary. Default: 768000.\n #[serde(default)]\n pub cycle_threshold: Option,\n /// Model used for seam/briefing work. Default: \"deepseek-v4-flash\".\n #[serde(default)]\n pub seam_model: Option,\n}\n\n/// Sub-agent model overrides. Keys in `models` can be role names (`worker`,\n/// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`,\n/// `review`, `custom`). Per-call explicit model choices still win.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SubagentsConfig {\n #[serde(default)]\n pub default_model: Option,\n #[serde(default)]\n pub worker_model: Option,\n #[serde(default)]\n pub explorer_model: Option,\n #[serde(default)]\n pub awaiter_model: Option,\n #[serde(default)]\n pub review_model: Option,\n #[serde(default)]\n pub custom_model: Option,\n #[serde(default)]\n pub models: Option>,\n /// Maximum concurrent sub-agents. Overrides the top-level max_subagents\n /// setting. Clamped to [1, MAX_SUBAGENTS].\n #[serde(default)]\n pub max_concurrent: Option,\n}\n\n/// Resolved CLI configuration, including defaults and environment overrides.\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct Config {\n pub provider: Option,\n pub api_key: Option,\n pub base_url: Option,\n /// Optional extra HTTP headers sent to model API requests.\n pub http_headers: Option>,\n pub default_text_model: Option,\n /// DeepSeek reasoning-effort tier: `\"off\" | \"low\" | \"medium\" | \"high\" | \"max\"`.\n /// Defaults to `\"max\"` at runtime if unset.\n pub reasoning_effort: Option,\n pub tools_file: Option,\n pub skills_dir: Option,\n pub mcp_config_path: Option,\n pub notes_path: Option,\n pub memory_path: Option,\n /// When true, set `tool_choice: \"required\"` and opt compatible function\n /// schemas into DeepSeek beta strict mode. Schemas with root alternatives\n /// stay non-strict to avoid changing optional/one-of tool semantics.\n pub strict_tool_mode: Option,\n /// Additional system-prompt sources concatenated in declared order\n /// (#454). Paths are expanded via `expand_path` so `~` and env\n /// vars work. Project config overrides user config (replace, not\n /// merge) — that's the typical \"this repo needs X plus everything\n /// I already have\" pattern, where users put `~/global.md` in the\n /// project's array if they want both. Each file is loaded, capped\n /// at 100 KiB, and skipped (with a warning) on read errors so a\n /// missing optional file doesn't fail the launch.\n pub instructions: Option>,\n pub allow_shell: Option,\n pub approval_policy: Option,\n pub sandbox_mode: Option,\n pub yolo: Option,\n /// External sandbox backend: `\"none\"` or `\"opensandbox\"`.\n /// When set, exec_shell routes commands through the backend's HTTP API\n /// instead of spawning a local process.\n pub sandbox_backend: Option,\n /// Base URL for the external sandbox backend (default: `\"http://localhost:8080\"`).\n pub sandbox_url: Option,\n /// Optional API key for the external sandbox backend (sent as Bearer token).\n pub sandbox_api_key: Option,\n pub managed_config_path: Option,\n pub requirements_path: Option,\n pub max_subagents: Option,\n pub retry: Option,\n pub capacity: Option,\n pub features: Option,\n\n /// TUI configuration (alternate screen, etc.)\n pub tui: Option,\n\n /// Lifecycle hooks configuration\n #[serde(default)]\n pub hooks: Option,\n\n /// Provider-specific credentials and defaults shared with the `deepseek` facade.\n #[serde(default)]\n pub providers: Option,\n\n /// Desktop notification settings (OSC 9 / BEL on long turn completion).\n #[serde(default)]\n pub notifications: Option,\n\n /// Per-domain network policy (#135). When absent, network tools fall back\n /// to a permissive default that mirrors pre-v0.7.0 behavior.\n #[serde(default)]\n pub network: Option,\n\n /// Community skill installer settings (#140). When absent, installer\n /// commands fall back to the bundled defaults\n /// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +\n /// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).\n #[serde(default)]\n pub skills: Option,\n\n /// Workspace side-git snapshots (#137). Defaults to enabled with 7-day\n /// retention when the table is absent.\n #[serde(default)]\n pub snapshots: Option,\n\n /// User-level memory file (#489). Default behaviour is **opt-in**:\n /// loading + injection happens only when `[memory] enabled = true` or\n /// `DEEPSEEK_MEMORY=on` is set.\n #[serde(default)]\n pub memory: Option,\n\n /// Post-edit LSP diagnostics injection (#136). When absent, the engine\n /// applies the defaults documented in [`LspConfigToml`].\n #[serde(default)]\n pub lsp: Option,\n\n /// Append-only layered context management with Flash seam manager (#159).\n #[serde(default)]\n pub context: ContextConfig,\n\n /// Sub-agent model overrides.\n #[serde(default)]\n pub subagents: Option,\n\n /// Runtime API server tuning (`deepseek serve --http`). Currently only\n /// hosts the CORS allow-list extension (whalescale#255 / #561). When the\n /// table is absent, the daemon ships with localhost:3000 / localhost:1420\n /// / tauri://localhost as the only allowed dev origins.\n #[serde(default)]\n pub runtime_api: Option,\n\n /// Workshop / large-tool-output routing (#548). When absent, the global\n /// default threshold of 4 096 tokens applies and routing is active.\n #[serde(default)]\n pub workshop: Option,\n}\n\n/// `[runtime_api]` table — knobs for the local HTTP/SSE daemon.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RuntimeApiConfig {\n /// Additional CORS origins to allow on top of the built-in defaults\n /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,\n /// `tauri://localhost`). Useful when developing a UI against a non-default\n /// dev server port (e.g. Vite's default `:5173`).\n ///\n /// Resolution order (highest priority first): `--cors-origin` CLI flag,\n /// `DEEPSEEK_CORS_ORIGINS` env var (comma-separated), this field. Whalescale#255 / #561.\n #[serde(default)]\n pub cors_origins: Option>,\n}\n\n/// `[skills]` table — knobs for the community-skill installer.\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct SkillsConfig {\n /// Curated registry index. `/skill install ` looks up the spec here.\n /// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].\n #[serde(default)]\n pub registry_url: Option,\n /// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed\n /// this limit are rejected during validation. Defaults to 5 MiB.\n #[serde(default)]\n pub max_install_size_bytes: Option,\n}\n\nimpl SkillsConfig {\n /// Resolve the registry URL with the bundled default.\n #[must_use]\n pub fn registry_url(&self) -> String {\n self.registry_url\n .clone()\n .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())\n }\n\n /// Resolve the max install size with the bundled default.\n #[must_use]\n pub fn max_install_size_bytes(&self) -> u64 {\n self.max_install_size_bytes\n .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)\n }\n}\n\n/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live\n/// TUI runtime can construct a [`crate::network_policy::NetworkPolicy`]\n/// without reaching into the workspace config crate. See `config.example.toml`\n/// for documentation.\n#[derive(Debug, Clone, Deserialize)]\npub struct NetworkPolicyToml {\n /// Decision for hosts that are not in `allow` or `deny`. One of\n /// `\"allow\" | \"deny\" | \"prompt\"`. Defaults to `\"prompt\"`.\n #[serde(default = \"default_network_decision\")]\n pub default: String,\n /// Hosts that are always allowed. Subdomain rules: a leading dot\n /// (`.example.com`) matches subdomains but not the apex.\n #[serde(default)]\n pub allow: Vec,\n /// Hosts that are always denied. Deny entries win over allow entries.\n #[serde(default)]\n pub deny: Vec,\n /// Hostnames whose DNS may resolve to fake-IP/private proxy ranges in an\n /// explicitly trusted proxy setup. Literal IP URLs remain blocked.\n #[serde(default)]\n pub proxy: Vec,\n /// Whether to record one audit-log line per outbound network call.\n #[serde(default = \"default_network_audit\")]\n pub audit: bool,\n}\n\nfn default_network_decision() -> String {\n \"prompt\".to_string()\n}\n\nfn default_network_audit() -> bool {\n true\n}\n\nimpl Default for NetworkPolicyToml {\n fn default() -> Self {\n Self {\n default: default_network_decision(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: Vec::new(),\n audit: default_network_audit(),\n }\n }\n}\n\nimpl NetworkPolicyToml {\n /// Build a runtime [`crate::network_policy::NetworkPolicy`] from the\n /// on-disk schema.\n #[must_use]\n pub fn into_runtime(self) -> crate::network_policy::NetworkPolicy {\n crate::network_policy::NetworkPolicy {\n default: crate::network_policy::Decision::parse(&self.default).into(),\n allow: self.allow,\n deny: self.deny,\n proxy: self.proxy,\n audit: self.audit,\n }\n }\n}\n\n/// `[lsp]` table — mirrors [`crate::lsp::LspConfig`]. Documented in\n/// `config.example.toml`. When omitted, defaults from `LspConfig::default()`\n/// apply (enabled, 5 s poll, 20 diagnostics/file, errors only, no overrides).\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct LspConfigToml {\n /// Master switch. Defaults to `true`.\n #[serde(default)]\n pub enabled: Option,\n /// How long to wait for the LSP server to publish diagnostics after a\n /// `didOpen`/`didChange`. Defaults to 5000 ms.\n #[serde(default)]\n pub poll_after_edit_ms: Option,\n /// Cap on diagnostics surfaced per file. Defaults to 20.\n #[serde(default)]\n pub max_diagnostics_per_file: Option,\n /// Whether to surface warnings in addition to errors. Defaults to `false`.\n #[serde(default)]\n pub include_warnings: Option,\n /// Optional override for the `Language -> [cmd, ...args]` table. Keys\n /// are language slugs (`\"rust\"`, `\"go\"`, etc.).\n #[serde(default)]\n pub servers: Option>>,\n}\n\nimpl LspConfigToml {\n /// Build a runtime [`crate::lsp::LspConfig`] from the on-disk schema,\n /// falling back to defaults for any unset fields.\n #[must_use]\n pub fn into_runtime(self) -> crate::lsp::LspConfig {\n let defaults = crate::lsp::LspConfig::default();\n crate::lsp::LspConfig {\n enabled: self.enabled.unwrap_or(defaults.enabled),\n poll_after_edit_ms: self\n .poll_after_edit_ms\n .unwrap_or(defaults.poll_after_edit_ms),\n max_diagnostics_per_file: self\n .max_diagnostics_per_file\n .unwrap_or(defaults.max_diagnostics_per_file),\n include_warnings: self.include_warnings.unwrap_or(defaults.include_warnings),\n servers: self.servers.unwrap_or_default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProviderConfig {\n pub api_key: Option,\n pub base_url: Option,\n pub model: Option,\n pub http_headers: Option>,\n}\n\n#[derive(Debug, Clone, Default, Deserialize)]\npub struct ProvidersConfig {\n #[serde(default)]\n pub deepseek: ProviderConfig,\n #[serde(default)]\n pub deepseek_cn: ProviderConfig,\n #[serde(default)]\n pub nvidia_nim: ProviderConfig,\n #[serde(default)]\n pub openai: ProviderConfig,\n #[serde(default)]\n pub openrouter: ProviderConfig,\n #[serde(default)]\n pub novita: ProviderConfig,\n #[serde(default)]\n pub fireworks: ProviderConfig,\n #[serde(default)]\n pub sglang: ProviderConfig,\n #[serde(default)]\n pub vllm: ProviderConfig,\n #[serde(default)]\n pub ollama: ProviderConfig,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct ConfigFile {\n #[serde(flatten)]\n base: Config,\n profiles: Option>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\nstruct RequirementsFile {\n #[serde(default)]\n allowed_approval_policies: Vec,\n #[serde(default)]\n allowed_sandbox_modes: Vec,\n}\n\n// === Config Loading ===\n\nimpl Config {\n /// Load configuration from disk and merge with environment overrides.\n ///\n /// # Examples\n ///\n /// ```ignore\n /// # use crate::config::Config;\n /// let config = Config::load(None, None)?;\n /// # Ok::<(), anyhow::Error>(())\n /// ```\n pub fn load(path: Option, profile: Option<&str>) -> Result {\n let path = resolve_load_config_path(path);\n let mut config = if let Some(path) = path.as_ref() {\n if path.exists() {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n apply_profile(parsed, profile)?\n } else {\n Config::default()\n }\n } else {\n Config::default()\n };\n\n apply_env_overrides(&mut config);\n apply_managed_overrides(&mut config)?;\n apply_requirements(&mut config)?;\n normalize_model_config(&mut config);\n config.validate()?;\n config.warn_on_misplaced_root_base_url();\n Ok(config)\n }\n\n /// Surface a one-line warning when the user has set the legacy root\n /// `base_url` field but their active provider is not DeepSeek (the only\n /// provider that actually reads that field, plus an NvidiaNim back-compat\n /// sniff). Common confusion: users add `base_url = \"...\"` at the top of\n /// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers\n /// and wonder why it's silently ignored (#1308).\n fn warn_on_misplaced_root_base_url(&self) {\n let Some(root_base) = self.base_url.as_deref().map(str::trim) else {\n return;\n };\n if root_base.is_empty() {\n return;\n }\n let provider = self.api_provider();\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && root_base.contains(\"integrate.api.nvidia.com\")\n {\n return;\n }\n // Only warn if the per-provider table doesn't have an explicit\n // `base_url`, because if it does, the per-provider one wins and the\n // root field is just dead config — no behavior surprise.\n let has_provider_base = self\n .provider_config_for(provider)\n .and_then(|p| p.base_url.as_deref().map(str::trim))\n .is_some_and(|s| !s.is_empty());\n if has_provider_base {\n return;\n }\n let table = match provider {\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => return,\n };\n tracing::warn!(\n \"Top-level `base_url = \\\"{root_base}\\\"` is ignored for the {provider:?} provider. \\\n Move it under `[{table}]` (e.g. `[{table}]\\\\nbase_url = \\\"...\\\"`) \\\n or set the corresponding `*_BASE_URL` env var. (#1308)\"\n );\n }\n\n /// Validate that critical config fields are present.\n pub fn validate(&self) -> Result<()> {\n if let Some(provider) = self.provider.as_deref()\n && ApiProvider::parse(provider).is_none()\n {\n anyhow::bail!(\n \"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, openrouter, novita, fireworks, sglang, vllm, or ollama.\"\n );\n }\n if let Some(ref key) = self.api_key\n && key.trim().is_empty()\n {\n anyhow::bail!(\"api_key cannot be empty string\");\n }\n if let Some(features) = &self.features {\n for key in features.entries.keys() {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && !model.trim().eq_ignore_ascii_case(\"auto\")\n && !provider_passes_model_through(self.api_provider())\n && !self.active_provider_preserves_custom_base_url_model()\n && normalize_model_name(model).is_none()\n {\n anyhow::bail!(\n \"Invalid default_text_model '{model}': expected auto or a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro).\"\n );\n }\n if let Some(policy) = self.approval_policy.as_deref() {\n let normalized = policy.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"on-request\" | \"untrusted\" | \"never\" | \"auto\" | \"suggest\"\n ) {\n anyhow::bail!(\n \"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest.\"\n );\n }\n }\n if let Some(mode) = self.sandbox_mode.as_deref() {\n let normalized = mode.trim().to_ascii_lowercase();\n if !matches!(\n normalized.as_str(),\n \"read-only\" | \"workspace-write\" | \"danger-full-access\" | \"external-sandbox\"\n ) {\n anyhow::bail!(\n \"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox.\"\n );\n }\n }\n if let Some(tui) = &self.tui\n && let Some(mode) = tui.alternate_screen.as_deref()\n {\n let mode = mode.to_ascii_lowercase();\n if !matches!(mode.as_str(), \"auto\" | \"always\" | \"never\") {\n anyhow::bail!(\n \"Invalid tui.alternate_screen '{mode}': expected auto, always, or never.\"\n );\n }\n }\n if let Some(capacity) = &self.capacity {\n if let Some(v) = capacity.low_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let Some(v) = capacity.medium_risk_max\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)\n && low > medium\n {\n anyhow::bail!(\n \"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium}).\"\n );\n }\n if let Some(v) = capacity.severe_violation_ratio\n && !(0.0..=1.0).contains(&v)\n {\n anyhow::bail!(\n \"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0].\"\n );\n }\n }\n Ok(())\n }\n\n #[must_use]\n pub fn api_provider(&self) -> ApiProvider {\n self.provider\n .as_deref()\n .and_then(ApiProvider::parse)\n .unwrap_or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .map(|_| ApiProvider::NvidiaNim)\n .or_else(|| {\n self.base_url\n .as_deref()\n .filter(|base| base.contains(\"api.deepseeki.com\"))\n .map(|_| ApiProvider::DeepseekCN)\n })\n .unwrap_or(ApiProvider::Deepseek)\n })\n }\n\n pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {\n let providers = self.providers.as_ref()?;\n Some(match provider {\n ApiProvider::Deepseek => &providers.deepseek,\n ApiProvider::DeepseekCN => &providers.deepseek_cn,\n ApiProvider::NvidiaNim => &providers.nvidia_nim,\n ApiProvider::Openai => &providers.openai,\n ApiProvider::Openrouter => &providers.openrouter,\n ApiProvider::Novita => &providers.novita,\n ApiProvider::Fireworks => &providers.fireworks,\n ApiProvider::Sglang => &providers.sglang,\n ApiProvider::Vllm => &providers.vllm,\n ApiProvider::Ollama => &providers.ollama,\n })\n }\n\n pub(crate) fn provider_config(&self) -> Option<&ProviderConfig> {\n self.provider_config_for(self.api_provider())\n }\n\n #[must_use]\n pub fn http_headers(&self) -> HashMap {\n let mut headers = self.http_headers.clone().unwrap_or_default();\n if let Some(provider_headers) = self\n .provider_config()\n .and_then(|provider| provider.http_headers.as_ref())\n {\n headers.extend(provider_headers.clone());\n }\n headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());\n headers\n }\n\n #[must_use]\n pub fn default_model(&self) -> String {\n let provider = self.api_provider();\n if let Some(model) = self\n .provider_config()\n .and_then(|provider| provider.model.as_deref())\n {\n if provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model()\n {\n return model.trim().to_string();\n }\n if let Some(normalized) = normalize_model_for_provider(provider, model) {\n return normalized;\n }\n }\n if let Some(model) = self.default_text_model.as_deref()\n && (provider_passes_model_through(provider)\n || self.active_provider_preserves_custom_base_url_model())\n {\n return model.trim().to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && model.trim().eq_ignore_ascii_case(\"auto\")\n {\n return \"auto\".to_string();\n }\n if let Some(model) = self.default_text_model.as_deref()\n && let Some(normalized) = normalize_model_name(model)\n {\n return model_for_provider(provider, normalized);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,\n ApiProvider::Openai => DEFAULT_OPENAI_MODEL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,\n ApiProvider::Novita => DEFAULT_NOVITA_MODEL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,\n ApiProvider::Sglang => DEFAULT_SGLANG_MODEL,\n ApiProvider::Vllm => DEFAULT_VLLM_MODEL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL,\n }\n .to_string()\n }\n\n /// Return the configured API base URL (normalized).\n #[must_use]\n pub fn deepseek_base_url(&self) -> String {\n let provider = self.api_provider();\n let provider_base = self\n .provider_config_for(provider)\n .and_then(|provider| provider.base_url.clone());\n // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a\n // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita\n // were added in v0.6.7 and require explicit `[providers.]`\n // entries or the corresponding `*_BASE_URL` env var.\n let root_base = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),\n ApiProvider::NvidiaNim => self\n .base_url\n .as_ref()\n .filter(|base| base.contains(\"integrate.api.nvidia.com\"))\n .cloned(),\n ApiProvider::Openai\n | ApiProvider::Openrouter\n | ApiProvider::Novita\n | ApiProvider::Fireworks\n | ApiProvider::Sglang\n | ApiProvider::Vllm\n | ApiProvider::Ollama => None,\n };\n let base = provider_base.or(root_base).unwrap_or_else(|| {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n .to_string()\n });\n normalize_base_url(&base)\n }\n\n fn active_provider_preserves_custom_base_url_model(&self) -> bool {\n let provider = self.api_provider();\n provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())\n }\n\n /// Read the API key.\n ///\n /// Precedence: **explicit in-memory override → provider/root config\n /// → environment**.\n ///\n /// The in-memory `self.api_key` override is only honored when the user\n /// explicitly set the field (not the legacy `API_KEYRING_SENTINEL`\n /// placeholder, not empty whitespace).\n pub fn deepseek_api_key(&self) -> Result {\n let provider = self.api_provider();\n let slot = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"deepseek\",\n ApiProvider::NvidiaNim => \"nvidia-nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n\n // 0. DeepSeek compatibility slot. The legacy top-level `api_key`\n // belongs to DeepSeek only; provider-specific keys below must win for\n // NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && let Some(configured) = self.api_key.as_ref()\n && !configured.trim().is_empty()\n && configured != API_KEYRING_SENTINEL\n {\n return Ok(configured.clone());\n }\n\n // 1. Config file (provider-scoped slot). This intentionally wins\n // over ambient env so `deepseek auth set` fixes stale shell exports.\n if let Some(configured) = self\n .provider_config_for(provider)\n .and_then(|provider| provider.api_key.clone())\n && !configured.trim().is_empty()\n {\n return Ok(configured);\n }\n\n // 2. Environment variables. Do not query platform credential stores\n // here; routine startup and doctor checks must stay prompt-free.\n if let Some(value) = deepseek_secrets::env_for(slot)\n && !value.trim().is_empty()\n {\n return Ok(value);\n }\n\n match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(\n \"DeepSeek API key not found.\\n\\\n \\n\\\n 1. Get a key: https://platform.deepseek.com/api_keys\\n\\\n 2. Save it (works in every folder, no OS prompts):\\n\\\n deepseek auth set --provider deepseek\\n\\\n \\n\\\n Alternatives:\\n\\\n • export DEEPSEEK_API_KEY= (current shell only;\\n\\\n also note: zsh users — exports in ~/.zshrc only reach interactive\\n\\\n shells, prefer ~/.zshenv for everything)\\n\\\n • api_key = \\\"\\\" in ~/.deepseek/config.toml\"\n ),\n ApiProvider::NvidiaNim => anyhow::bail!(\n \"NVIDIA NIM API key not found. Run 'deepseek auth set --provider nvidia-nim', \\\n set NVIDIA_API_KEY/NVIDIA_NIM_API_KEY, or save api_key in ~/.deepseek/config.toml \\\n with provider = \\\"nvidia-nim\\\".\"\n ),\n ApiProvider::Openai => anyhow::bail!(\n \"OpenAI-compatible API key not found. Run 'deepseek auth set --provider openai', \\\n set OPENAI_API_KEY, or add [providers.openai] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Openrouter => anyhow::bail!(\n \"OpenRouter API key not found. Run 'deepseek auth set --provider openrouter', \\\n set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Novita => anyhow::bail!(\n \"Novita API key not found. Run 'deepseek auth set --provider novita', \\\n set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml.\"\n ),\n ApiProvider::Fireworks => anyhow::bail!(\n \"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \\\n set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml.\"\n ),\n // Self-hosted deployments commonly run without auth on localhost.\n // Return an empty key and let the client omit the Authorization header.\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()),\n }\n }\n\n /// Resolve the skills directory path.\n #[must_use]\n pub fn skills_dir(&self) -> PathBuf {\n self.skills_dir\n .as_deref()\n .map(expand_path)\n .or_else(default_skills_dir)\n .unwrap_or_else(|| PathBuf::from(\"./skills\"))\n }\n\n /// Resolve the MCP config path.\n #[must_use]\n pub fn mcp_config_path(&self) -> PathBuf {\n self.mcp_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_mcp_config_path)\n .unwrap_or_else(|| PathBuf::from(\"./mcp.json\"))\n }\n\n /// Resolve the notes file path.\n #[must_use]\n pub fn notes_path(&self) -> PathBuf {\n self.notes_path\n .as_deref()\n .map(expand_path)\n .or_else(default_notes_path)\n .unwrap_or_else(|| PathBuf::from(\"./notes.txt\"))\n }\n\n /// Resolve the memory file path.\n #[must_use]\n pub fn memory_path(&self) -> PathBuf {\n self.memory_path\n .as_deref()\n .map(expand_path)\n .or_else(default_memory_path)\n .unwrap_or_else(|| PathBuf::from(\"./memory.md\"))\n }\n\n /// Resolve the configured `instructions = [...]` array (#454)\n /// to absolute paths, in declared order. Empty when unset or\n /// when every entry is empty after trimming. Each entry runs\n /// through `expand_path` so `~` and env vars are honoured.\n #[must_use]\n pub fn instructions_paths(&self) -> Vec {\n self.instructions\n .as_deref()\n .unwrap_or(&[])\n .iter()\n .map(String::as_str)\n .map(str::trim)\n .filter(|s| !s.is_empty())\n .map(expand_path)\n .collect()\n }\n\n /// Whether the user-memory feature is enabled. The default is **off**\n /// to preserve zero-overhead behavior for users who haven't opted in.\n /// Flips to `true` when `[memory] enabled = true` in `config.toml` or\n /// `DEEPSEEK_MEMORY=on` is set in the environment.\n #[must_use]\n pub fn memory_enabled(&self) -> bool {\n self.memory\n .as_ref()\n .and_then(|m| m.enabled)\n .unwrap_or(false)\n }\n\n #[must_use]\n pub fn project_context_pack_enabled(&self) -> bool {\n self.context.project_pack.unwrap_or(true)\n }\n\n /// Return whether shell execution is allowed. Defaults to `false`: shell\n /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4).\n #[must_use]\n pub fn allow_shell(&self) -> bool {\n self.allow_shell.unwrap_or(false)\n }\n\n /// Return the maximum number of concurrent sub-agents.\n /// Checks `[subagents] max_concurrent` first, then top-level `max_subagents`,\n /// then falls back to `DEFAULT_MAX_SUBAGENTS`.\n #[must_use]\n pub fn max_subagents(&self) -> usize {\n // Check [subagents] max_concurrent first\n if let Some(subagents_cfg) = self.subagents.as_ref()\n && let Some(max) = subagents_cfg.max_concurrent\n {\n return max.clamp(1, MAX_SUBAGENTS);\n }\n // Fall back to top-level max_subagents\n self.max_subagents\n .unwrap_or(DEFAULT_MAX_SUBAGENTS)\n .clamp(1, MAX_SUBAGENTS)\n }\n\n /// Raw sub-agent model override map. Values are validated at spawn time\n /// so an invalid role/type model fails before any partial agent spawn.\n #[must_use]\n pub fn subagent_model_overrides(&self) -> HashMap {\n let mut overrides = HashMap::new();\n let Some(cfg) = self.subagents.as_ref() else {\n return overrides;\n };\n\n let mut insert = |key: &str, value: &Option| {\n if let Some(model) = value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {\n overrides.insert(key.to_string(), model.to_string());\n }\n };\n insert(\"default\", &cfg.default_model);\n insert(\"worker\", &cfg.worker_model);\n insert(\"general\", &cfg.worker_model);\n insert(\"explorer\", &cfg.explorer_model);\n insert(\"explore\", &cfg.explorer_model);\n insert(\"awaiter\", &cfg.awaiter_model);\n insert(\"plan\", &cfg.awaiter_model);\n insert(\"review\", &cfg.review_model);\n insert(\"custom\", &cfg.custom_model);\n\n if let Some(models) = cfg.models.as_ref() {\n for (key, model) in models {\n let key = key.trim();\n let model = model.trim();\n if !key.is_empty() && !model.is_empty() {\n overrides.insert(key.to_ascii_lowercase(), model.to_string());\n }\n }\n }\n\n overrides\n }\n\n /// Return the configured DeepSeek reasoning-effort tier, if any.\n #[must_use]\n pub fn reasoning_effort(&self) -> Option<&str> {\n self.reasoning_effort.as_deref()\n }\n\n /// Get hooks configuration, returning default if not configured.\n pub fn hooks_config(&self) -> HooksConfig {\n self.hooks.clone().unwrap_or_default()\n }\n\n /// Resolve the notifications configuration with defaults applied.\n #[must_use]\n pub fn notifications_config(&self) -> NotificationsConfig {\n self.notifications.clone().unwrap_or_default()\n }\n\n /// Resolve workspace side-git snapshot settings with defaults applied.\n #[must_use]\n pub fn snapshots_config(&self) -> SnapshotsConfig {\n self.snapshots.clone().unwrap_or_default()\n }\n\n /// Resolve enabled features from defaults and config entries.\n #[must_use]\n pub fn features(&self) -> Features {\n let mut features = Features::with_defaults();\n if let Some(table) = &self.features {\n features.apply_map(&table.entries);\n }\n features\n }\n\n /// Override a feature flag in memory (used by CLI overrides).\n pub fn set_feature(&mut self, key: &str, enabled: bool) -> Result<()> {\n if !is_known_feature_key(key) {\n anyhow::bail!(\"Unknown feature flag: {key}\");\n }\n let table = self.features.get_or_insert_with(FeaturesToml::default);\n table.entries.insert(key.to_string(), enabled);\n Ok(())\n }\n\n /// Resolve the effective retry policy with defaults applied.\n #[must_use]\n pub fn retry_policy(&self) -> RetryPolicy {\n let defaults = RetryPolicy {\n enabled: true,\n max_retries: 3,\n initial_delay: 1.0,\n max_delay: 60.0,\n exponential_base: 2.0,\n };\n\n let Some(cfg) = &self.retry else {\n return defaults;\n };\n\n RetryPolicy {\n enabled: cfg.enabled.unwrap_or(defaults.enabled),\n max_retries: cfg.max_retries.unwrap_or(defaults.max_retries),\n initial_delay: cfg.initial_delay.unwrap_or(defaults.initial_delay),\n max_delay: cfg.max_delay.unwrap_or(defaults.max_delay),\n exponential_base: cfg.exponential_base.unwrap_or(defaults.exponential_base),\n }\n }\n}\n\n// === Defaults ===\n\nfn default_config_path() -> Option {\n env_config_path().or_else(home_config_path)\n}\n\nfn effective_home_dir() -> Option {\n if let Some(path) = std::env::var_os(\"HOME\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n if let Some(path) = std::env::var_os(\"USERPROFILE\") {\n let path = PathBuf::from(path);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n\n #[cfg(windows)]\n {\n if let (Some(drive), Some(homepath)) =\n (std::env::var_os(\"HOMEDRIVE\"), std::env::var_os(\"HOMEPATH\"))\n {\n let mut path = PathBuf::from(drive);\n path.push(homepath);\n if !path.as_os_str().is_empty() {\n return Some(path);\n }\n }\n }\n\n dirs::home_dir()\n}\n\nfn home_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"config.toml\"))\n}\n\n#[must_use]\npub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {\n let Some(config_path) = default_config_path() else {\n return false;\n };\n let Ok(raw) = fs::read_to_string(config_path) else {\n return false;\n };\n let Ok(doc) = toml::from_str::(&raw) else {\n return false;\n };\n workspace_trust_level_from_doc(&doc, workspace).is_some_and(is_trusted_level)\n}\n\npub(crate) fn save_workspace_trust(workspace: &Path) -> Result {\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let mut doc = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str::(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let root = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let projects = root\n .entry(\"projects\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`projects` must be a table.\")?;\n let project = projects\n .entry(workspace_config_key(workspace))\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"Project entry must be a table.\")?;\n project.insert(\n \"trust_level\".to_string(),\n toml::Value::String(\"trusted\".to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(config_path)\n}\n\nfn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {\n let workspace = canonicalize_or_keep(workspace);\n let projects = doc.get(\"projects\")?.as_table()?;\n for (raw_path, project) in projects {\n let project_path = canonicalize_or_keep(&expand_path(raw_path));\n if project_path == workspace {\n return project.get(\"trust_level\").and_then(toml::Value::as_str);\n }\n }\n None\n}\n\nfn is_trusted_level(level: &str) -> bool {\n level.trim().eq_ignore_ascii_case(\"trusted\")\n}\n\nfn workspace_config_key(workspace: &Path) -> String {\n canonicalize_or_keep(workspace)\n .to_string_lossy()\n .into_owned()\n}\n\nfn canonicalize_or_keep(path: &Path) -> PathBuf {\n path.canonicalize().unwrap_or_else(|_| path.to_path_buf())\n}\n\nfn env_config_path() -> Option {\n if let Ok(path) = std::env::var(\"DEEPSEEK_CONFIG_PATH\") {\n let trimmed = path.trim();\n if !trimmed.is_empty() {\n return Some(expand_path(trimmed));\n }\n }\n None\n}\n\nfn expand_pathbuf(path: PathBuf) -> PathBuf {\n if let Some(raw) = path.to_str() {\n return expand_path(raw);\n }\n path\n}\n\nfn resolve_load_config_path(path: Option) -> Option {\n if let Some(path) = path {\n return Some(expand_pathbuf(path));\n }\n\n if let Some(path) = env_config_path() {\n if path.exists() {\n return Some(path);\n }\n\n if let Some(home_path) = home_config_path()\n && home_path.exists()\n {\n return Some(home_path);\n }\n\n return Some(path);\n }\n\n home_config_path()\n}\n\n/// Create an inspectable config file on first interactive launch.\n///\n/// The file intentionally omits `api_key`; onboarding or `deepseek auth set`\n/// writes that field after the user supplies a key.\npub fn ensure_config_file_exists(path: Option) -> Result> {\n let config_path = path\n .map(expand_pathbuf)\n .or_else(default_config_path)\n .context(\"Failed to resolve config path: home directory not found.\")?;\n if config_path.exists() {\n return Ok(None);\n }\n\n ensure_parent_dir(&config_path)?;\n let content = format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Save it with: deepseek auth set --provider deepseek\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"auto\" | \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"auto\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n );\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n Ok(Some(config_path))\n}\n\nfn default_managed_config_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/managed_config.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"managed_config.toml\"))\n }\n}\n\nfn default_requirements_path() -> Option {\n #[cfg(unix)]\n {\n Some(PathBuf::from(\"/etc/deepseek/requirements.toml\"))\n }\n #[cfg(not(unix))]\n {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"requirements.toml\"))\n }\n}\n\npub(crate) fn expand_path(path: &str) -> PathBuf {\n if let Some(stripped) = path.strip_prefix('~')\n && (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\\\'))\n && let Some(mut home) = effective_home_dir()\n {\n let suffix = stripped.trim_start_matches(['/', '\\\\']);\n if !suffix.is_empty() {\n home.push(suffix);\n }\n return home;\n }\n\n let expanded = shellexpand::tilde(path);\n PathBuf::from(expanded.as_ref())\n}\n\nfn default_skills_dir() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"skills\"))\n}\n\nfn default_mcp_config_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"mcp.json\"))\n}\n\nfn default_notes_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"notes.txt\"))\n}\n\nfn default_memory_path() -> Option {\n effective_home_dir().map(|home| home.join(\".deepseek\").join(\"memory.md\"))\n}\n\n// === Environment Overrides ===\n\nfn apply_env_overrides(config: &mut Config) {\n if let Ok(value) = std::env::var(\"DEEPSEEK_PROVIDER\") {\n config.provider = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_BASE_URL\") {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n config.base_url = Some(value);\n }\n ApiProvider::NvidiaNim => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n ApiProvider::Openai => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n ApiProvider::Openrouter => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n ApiProvider::Novita => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n ApiProvider::Fireworks => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n ApiProvider::Sglang => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n ApiProvider::Vllm => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n ApiProvider::Ollama => {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n }\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_BASE_URL\")\n .or_else(|_| std::env::var(\"NIM_BASE_URL\"))\n .or_else(|_| std::env::var(\"NVIDIA_BASE_URL\"))\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .nvidia_nim\n .base_url = Some(value);\n }\n // OpenAI-compatible and non-DeepSeek hosted providers are scoped only on\n // their own provider entry — the legacy root `base_url` keeps DeepSeek-only\n // semantics.\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openai\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openrouter)\n && let Ok(value) = std::env::var(\"OPENROUTER_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .openrouter\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Novita)\n && let Ok(value) = std::env::var(\"NOVITA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .novita\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Fireworks)\n && let Ok(value) = std::env::var(\"FIREWORKS_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .fireworks\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .sglang\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .vllm\n .base_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_HTTP_HEADERS\")\n && let Ok(headers) = parse_http_headers(&value)\n && !headers.is_empty()\n {\n let mut root_headers = config.http_headers.clone().unwrap_or_default();\n root_headers.extend(headers.clone());\n config.http_headers = Some(root_headers);\n\n let provider = config.api_provider();\n let providers = config\n .providers\n .get_or_insert_with(ProvidersConfig::default);\n let entry = match provider {\n ApiProvider::Deepseek => &mut providers.deepseek,\n ApiProvider::DeepseekCN => &mut providers.deepseek_cn,\n ApiProvider::NvidiaNim => &mut providers.nvidia_nim,\n ApiProvider::Openai => &mut providers.openai,\n ApiProvider::Openrouter => &mut providers.openrouter,\n ApiProvider::Novita => &mut providers.novita,\n ApiProvider::Fireworks => &mut providers.fireworks,\n ApiProvider::Sglang => &mut providers.sglang,\n ApiProvider::Vllm => &mut providers.vllm,\n ApiProvider::Ollama => &mut providers.ollama,\n };\n let mut provider_headers = entry.http_headers.clone().unwrap_or_default();\n provider_headers.extend(headers);\n entry.http_headers = Some(provider_headers);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_BASE_URL\")\n && !value.trim().is_empty()\n {\n config\n .providers\n .get_or_insert_with(ProvidersConfig::default)\n .ollama\n .base_url = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Sglang)\n && let Ok(value) = std::env::var(\"SGLANG_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Vllm)\n && let Ok(value) = std::env::var(\"VLLM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Ollama)\n && let Ok(value) = std::env::var(\"OLLAMA_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::Openai)\n && let Ok(value) = std::env::var(\"OPENAI_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) =\n std::env::var(\"DEEPSEEK_MODEL\").or_else(|_| std::env::var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\"))\n {\n config.default_text_model = Some(value);\n }\n if matches!(config.api_provider(), ApiProvider::NvidiaNim)\n && let Ok(value) = std::env::var(\"NVIDIA_NIM_MODEL\")\n {\n config.default_text_model = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SKILLS_DIR\") {\n config.skills_dir = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MCP_CONFIG\") {\n config.mcp_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_NOTES_PATH\") {\n config.notes_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY_PATH\") {\n config.memory_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MEMORY\") {\n let on = matches!(\n value.trim().to_ascii_lowercase().as_str(),\n \"1\" | \"on\" | \"true\" | \"yes\" | \"y\" | \"enabled\"\n );\n config\n .memory\n .get_or_insert_with(MemoryConfig::default)\n .enabled = Some(on);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_ALLOW_SHELL\") {\n config.allow_shell = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_APPROVAL_POLICY\") {\n config.approval_policy = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_MODE\") {\n config.sandbox_mode = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_YOLO\") {\n config.yolo = Some(value == \"1\" || value.eq_ignore_ascii_case(\"true\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_BACKEND\") {\n config.sandbox_backend = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_URL\") {\n config.sandbox_url = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_SANDBOX_API_KEY\") {\n config.sandbox_api_key = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MANAGED_CONFIG_PATH\") {\n config.managed_config_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_REQUIREMENTS_PATH\") {\n config.requirements_path = Some(value);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_MAX_SUBAGENTS\")\n && let Ok(parsed) = value.parse::()\n {\n config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));\n }\n\n let capacity = config.capacity.get_or_insert(CapacityConfig {\n enabled: None,\n low_risk_max: None,\n medium_risk_max: None,\n severe_min_slack: None,\n severe_violation_ratio: None,\n refresh_cooldown_turns: None,\n replan_cooldown_turns: None,\n max_replay_per_turn: None,\n min_turns_before_guardrail: None,\n profile_window: None,\n deepseek_v3_2_chat_prior: None,\n deepseek_v3_2_reasoner_prior: None,\n deepseek_v4_pro_prior: None,\n deepseek_v4_flash_prior: None,\n fallback_default_prior: None,\n });\n\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_ENABLED\") {\n let val = value.trim().to_ascii_lowercase();\n capacity.enabled = Some(matches!(val.as_str(), \"1\" | \"true\" | \"yes\" | \"on\"));\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_LOW_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.low_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.medium_risk_max = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_min_slack = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.severe_violation_ratio = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.refresh_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.replan_cooldown_turns = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.max_replay_per_turn = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.min_turns_before_guardrail = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PROFILE_WINDOW\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.profile_window = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_CHAT\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_chat_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_REASONER\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v3_2_reasoner_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_PRO\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_pro_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_V4_FLASH\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.deepseek_v4_flash_prior = Some(parsed);\n }\n if let Ok(value) = std::env::var(\"DEEPSEEK_CAPACITY_PRIOR_FALLBACK\")\n && let Ok(parsed) = value.parse::()\n {\n capacity.fallback_default_prior = Some(parsed);\n }\n\n if config.capacity.as_ref().is_some_and(|c| {\n c.enabled.is_none()\n && c.low_risk_max.is_none()\n && c.medium_risk_max.is_none()\n && c.severe_min_slack.is_none()\n && c.severe_violation_ratio.is_none()\n && c.refresh_cooldown_turns.is_none()\n && c.replan_cooldown_turns.is_none()\n && c.max_replay_per_turn.is_none()\n && c.min_turns_before_guardrail.is_none()\n && c.profile_window.is_none()\n && c.deepseek_v3_2_chat_prior.is_none()\n && c.deepseek_v3_2_reasoner_prior.is_none()\n && c.deepseek_v4_pro_prior.is_none()\n && c.deepseek_v4_flash_prior.is_none()\n && c.fallback_default_prior.is_none()\n }) {\n config.capacity = None;\n }\n}\n\nfn normalize_model_config(config: &mut Config) {\n if let Some(model) = config.default_text_model.as_deref()\n && !provider_passes_model_through(config.api_provider())\n && !config.active_provider_preserves_custom_base_url_model()\n && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)\n {\n config.default_text_model = Some(normalized);\n }\n\n if let Some(providers) = config.providers.as_mut() {\n if let Some(model) = providers.deepseek.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)\n {\n providers.deepseek.model = Some(normalized);\n }\n if let Some(model) = providers.deepseek_cn.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)\n {\n providers.deepseek_cn.model = Some(normalized);\n }\n if let Some(model) = providers.nvidia_nim.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)\n {\n providers.nvidia_nim.model = Some(normalized);\n }\n if let Some(model) = providers.openrouter.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)\n {\n providers.openrouter.model = Some(normalized);\n }\n if let Some(model) = providers.novita.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)\n {\n providers.novita.model = Some(normalized);\n }\n if let Some(model) = providers.fireworks.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)\n {\n providers.fireworks.model = Some(normalized);\n }\n if let Some(model) = providers.sglang.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)\n {\n providers.sglang.model = Some(normalized);\n }\n if let Some(model) = providers.vllm.model.as_deref()\n && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)\n && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)\n {\n providers.vllm.model = Some(normalized);\n }\n }\n}\n\nfn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option {\n if provider_passes_model_through(provider) {\n return None;\n }\n normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))\n}\n\nfn provider_passes_model_through(provider: ApiProvider) -> bool {\n matches!(provider, ApiProvider::Openai | ApiProvider::Ollama)\n}\n\nfn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {\n entry\n .base_url\n .as_deref()\n .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))\n}\n\nfn default_base_url_for_provider(provider: ApiProvider) -> &'static str {\n match provider {\n ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,\n ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL,\n ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,\n ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL,\n ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,\n ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,\n ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,\n ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL,\n ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL,\n ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL,\n }\n}\n\nfn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool {\n normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider))\n}\n\nfn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool {\n matches!(provider, ApiProvider::Openrouter)\n && base_url_is_custom_for_provider(provider, base_url)\n}\n\nfn model_for_provider(provider: ApiProvider, normalized: String) -> String {\n let lowered = normalized.to_ascii_lowercase();\n match (provider, lowered.as_str()) {\n (ApiProvider::NvidiaNim, \"deepseek-v4-pro\") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),\n (ApiProvider::NvidiaNim, \"deepseek-v4-flash\") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-pro\") => DEFAULT_OPENROUTER_MODEL.to_string(),\n (ApiProvider::Openrouter, \"deepseek-v4-flash\") => {\n DEFAULT_OPENROUTER_FLASH_MODEL.to_string()\n }\n (ApiProvider::Novita, \"deepseek-v4-pro\") => DEFAULT_NOVITA_MODEL.to_string(),\n (ApiProvider::Novita, \"deepseek-v4-flash\") => DEFAULT_NOVITA_FLASH_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-pro\") => DEFAULT_FIREWORKS_MODEL.to_string(),\n (ApiProvider::Fireworks, \"deepseek-v4-flash\") => {\n // Flash not yet available on Fireworks; fall through to normalized name\n \"accounts/fireworks/models/deepseek-v4-flash\".to_string()\n }\n (ApiProvider::Sglang, \"deepseek-v4-pro\") => DEFAULT_SGLANG_MODEL.to_string(),\n (ApiProvider::Sglang, \"deepseek-v4-flash\") => DEFAULT_SGLANG_FLASH_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-pro\") => DEFAULT_VLLM_MODEL.to_string(),\n (ApiProvider::Vllm, \"deepseek-v4-flash\") => DEFAULT_VLLM_FLASH_MODEL.to_string(),\n _ => normalized,\n }\n}\n\nfn normalize_base_url(base: &str) -> String {\n let trimmed = base.trim_end_matches('/');\n let deepseek_domains = [\"api.deepseek.com\", \"api.deepseeki.com\"];\n if deepseek_domains\n .iter()\n .any(|domain| trimmed.contains(domain))\n {\n return trimmed.trim_end_matches(\"/v1\").to_string();\n }\n trimmed.to_string()\n}\n\nfn parse_http_headers(raw: &str) -> Result> {\n let mut headers = HashMap::new();\n for pair in raw.trim().split(',') {\n let pair = pair.trim();\n if pair.is_empty() {\n continue;\n }\n let Some((name, value)) = pair.split_once('=') else {\n anyhow::bail!(\"invalid header pair '{pair}', expected name=value\");\n };\n let name = name.trim();\n let value = value.trim();\n if name.is_empty() {\n anyhow::bail!(\"header name cannot be empty\");\n }\n if value.is_empty() {\n continue;\n }\n headers.insert(name.to_string(), value.to_string());\n }\n Ok(headers)\n}\n\nfn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result {\n if let Some(profile_name) = profile {\n let profiles = config.profiles.as_ref();\n match profiles.and_then(|profiles| profiles.get(profile_name)) {\n Some(override_cfg) => Ok(merge_config(config.base, override_cfg.clone())),\n None => {\n let available = profiles\n .map(|profiles| {\n let mut keys = profiles.keys().cloned().collect::>();\n keys.sort();\n if keys.is_empty() {\n \"none\".to_string()\n } else {\n keys.join(\", \")\n }\n })\n .unwrap_or_else(|| \"none\".to_string());\n anyhow::bail!(\n \"Profile '{}' not found. Available profiles: {}\",\n profile_name,\n available\n )\n }\n }\n } else {\n Ok(config.base)\n }\n}\n\nfn merge_config(base: Config, override_cfg: Config) -> Config {\n Config {\n provider: override_cfg.provider.or(base.provider),\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n default_text_model: override_cfg.default_text_model.or(base.default_text_model),\n reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),\n tools_file: override_cfg.tools_file.or(base.tools_file),\n skills_dir: override_cfg.skills_dir.or(base.skills_dir),\n mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),\n notes_path: override_cfg.notes_path.or(base.notes_path),\n memory_path: override_cfg.memory_path.or(base.memory_path),\n // #454: project's instructions array replaces user's array\n // wholesale. The typical \"merge\" pattern is for users who want\n // both — they list `~/global.md` inside the project array.\n instructions: override_cfg.instructions.or(base.instructions),\n allow_shell: override_cfg.allow_shell.or(base.allow_shell),\n yolo: override_cfg.yolo.or(base.yolo),\n approval_policy: override_cfg.approval_policy.or(base.approval_policy),\n sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),\n sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend),\n sandbox_url: override_cfg.sandbox_url.or(base.sandbox_url),\n sandbox_api_key: override_cfg.sandbox_api_key.or(base.sandbox_api_key),\n managed_config_path: override_cfg\n .managed_config_path\n .or(base.managed_config_path),\n requirements_path: override_cfg.requirements_path.or(base.requirements_path),\n max_subagents: override_cfg.max_subagents.or(base.max_subagents),\n retry: override_cfg.retry.or(base.retry),\n capacity: override_cfg.capacity.or(base.capacity),\n tui: override_cfg.tui.or(base.tui),\n hooks: override_cfg.hooks.or(base.hooks),\n providers: merge_providers(base.providers, override_cfg.providers),\n features: merge_features(base.features, override_cfg.features),\n notifications: override_cfg.notifications.or(base.notifications),\n network: override_cfg.network.or(base.network),\n skills: override_cfg.skills.or(base.skills),\n snapshots: override_cfg.snapshots.or(base.snapshots),\n memory: override_cfg.memory.or(base.memory),\n lsp: override_cfg.lsp.or(base.lsp),\n context: ContextConfig {\n enabled: override_cfg.context.enabled.or(base.context.enabled),\n project_pack: override_cfg\n .context\n .project_pack\n .or(base.context.project_pack),\n verbatim_window_turns: override_cfg\n .context\n .verbatim_window_turns\n .or(base.context.verbatim_window_turns),\n l1_threshold: override_cfg\n .context\n .l1_threshold\n .or(base.context.l1_threshold),\n l2_threshold: override_cfg\n .context\n .l2_threshold\n .or(base.context.l2_threshold),\n l3_threshold: override_cfg\n .context\n .l3_threshold\n .or(base.context.l3_threshold),\n cycle_threshold: override_cfg\n .context\n .cycle_threshold\n .or(base.context.cycle_threshold),\n seam_model: override_cfg.context.seam_model.or(base.context.seam_model),\n },\n subagents: override_cfg.subagents.or(base.subagents),\n strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode),\n runtime_api: override_cfg.runtime_api.or(base.runtime_api),\n workshop: override_cfg.workshop.or(base.workshop),\n }\n}\n\nfn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {\n ProviderConfig {\n api_key: override_cfg.api_key.or(base.api_key),\n base_url: override_cfg.base_url.or(base.base_url),\n model: override_cfg.model.or(base.model),\n http_headers: override_cfg.http_headers.or(base.http_headers),\n }\n}\n\nfn merge_providers(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n (Some(base), Some(override_cfg)) => Some(ProvidersConfig {\n deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),\n deepseek_cn: merge_provider_config(base.deepseek_cn, override_cfg.deepseek_cn),\n nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),\n openai: merge_provider_config(base.openai, override_cfg.openai),\n openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),\n novita: merge_provider_config(base.novita, override_cfg.novita),\n fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),\n sglang: merge_provider_config(base.sglang, override_cfg.sglang),\n vllm: merge_provider_config(base.vllm, override_cfg.vllm),\n ollama: merge_provider_config(base.ollama, override_cfg.ollama),\n }),\n }\n}\n\nfn load_single_config_file(path: &Path) -> Result {\n let contents = fs::read_to_string(path)\n .with_context(|| format!(\"Failed to read config file: {}\", path.display()))?;\n let parsed: ConfigFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse config file: {}\", path.display()))?;\n Ok(parsed.base)\n}\n\nfn apply_managed_overrides(config: &mut Config) -> Result<()> {\n let path = config\n .managed_config_path\n .as_deref()\n .map(expand_path)\n .or_else(default_managed_config_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let managed = load_single_config_file(&path)?;\n *config = merge_config(config.clone(), managed);\n Ok(())\n}\n\nfn apply_requirements(config: &mut Config) -> Result<()> {\n let path = config\n .requirements_path\n .as_deref()\n .map(expand_path)\n .or_else(default_requirements_path);\n let Some(path) = path else {\n return Ok(());\n };\n if !path.exists() {\n return Ok(());\n }\n let contents = fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read requirements file: {}\", path.display()))?;\n let requirements: RequirementsFile = toml::from_str(&contents)\n .with_context(|| format!(\"Failed to parse requirements file: {}\", path.display()))?;\n\n if !requirements.allowed_approval_policies.is_empty()\n && let Some(policy) = config.approval_policy.as_ref()\n {\n let policy = policy.to_ascii_lowercase();\n if !requirements\n .allowed_approval_policies\n .iter()\n .any(|p| p.eq_ignore_ascii_case(&policy))\n {\n anyhow::bail!(\n \"approval_policy '{policy}' is not allowed by requirements ({})\",\n requirements.allowed_approval_policies.join(\", \")\n );\n }\n }\n if !requirements.allowed_sandbox_modes.is_empty()\n && let Some(mode) = config.sandbox_mode.as_ref()\n {\n let mode = mode.to_ascii_lowercase();\n if !requirements\n .allowed_sandbox_modes\n .iter()\n .any(|m| m.eq_ignore_ascii_case(&mode))\n {\n anyhow::bail!(\n \"sandbox_mode '{mode}' is not allowed by requirements ({})\",\n requirements.allowed_sandbox_modes.join(\", \")\n );\n }\n }\n\n Ok(())\n}\n\nfn merge_features(\n base: Option,\n override_cfg: Option,\n) -> Option {\n match (base, override_cfg) {\n (None, None) => None,\n (Some(mut base), Some(override_cfg)) => {\n for (key, value) in override_cfg.entries {\n base.entries.insert(key, value);\n }\n Some(base)\n }\n (Some(base), None) => Some(base),\n (None, Some(override_cfg)) => Some(override_cfg),\n }\n}\n\npub fn ensure_parent_dir(path: &Path) -> Result<()> {\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent)\n .with_context(|| format!(\"Failed to create directory: {}\", parent.display()))?;\n #[cfg(unix)]\n {\n // Tighten group/other bits on the parent dir as a hardening pass.\n // The dir lives under the user's home, so the chmod is best-effort:\n // filesystems that don't accept Unix permission bits (Docker\n // bind-mounts of NTFS, network shares, FAT, certain CI volumes —\n // see #897) return EPERM/ENOTSUP. The dir already exists by the\n // time we get here, so failing the whole save just because we\n // couldn't tighten perms strands the user mid-onboarding. Warn\n // loudly so a security-sensitive operator can still notice via\n // `RUST_LOG=warn`, then continue.\n if let Ok(meta) = fs::metadata(parent) {\n let mode = meta.permissions().mode();\n if mode & 0o077 != 0 {\n let mut perms = meta.permissions();\n perms.set_mode(mode & !0o077);\n if let Err(err) = fs::set_permissions(parent, perms) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %parent.display(),\n error = %err,\n \"could not tighten parent dir permissions; \\\n filesystem may not support Unix chmod \\\n (Docker bind-mount, NTFS, network share). \\\n Continuing — the file will still be written.\"\n );\n }\n }\n }\n }\n }\n Ok(())\n}\n\n/// Write content to a config file with restrictive permissions (owner-only read/write).\n/// On Unix this sets mode 0o600 before writing.\nfn write_config_file_secure(path: &Path, content: &str) -> Result<()> {\n #[cfg(unix)]\n {\n let mut file = fs::OpenOptions::new()\n .write(true)\n .create(true)\n .truncate(true)\n .mode(0o600)\n .open(path)?;\n file.write_all(content.as_bytes())?;\n // The file was already opened with mode 0o600; the explicit\n // set_permissions re-asserts that on filesystems where mode-at-open\n // didn't take effect (or where the file already existed with broader\n // bits). Filesystems that don't accept Unix chmod at all (Docker\n // bind-mounts of NTFS, network shares — #897) return EPERM. Treat\n // that as a warning rather than failing the whole save: the file\n // contents are written, and on Windows/macOS hosts the parent file\n // system's native ACL model is doing the access control.\n if let Err(err) = file.set_permissions(fs::Permissions::from_mode(0o600)) {\n tracing::warn!(\n target: \"deepseek::config\",\n path = %path.display(),\n error = %err,\n \"could not enforce 0o600 on config file; filesystem may \\\n not support Unix chmod. File contents written; rely on \\\n host ACLs for access control.\"\n );\n }\n }\n #[cfg(not(unix))]\n {\n fs::write(path, content)?;\n }\n Ok(())\n}\n\n/// Where a saved credential ended up. Returned by [`save_api_key`] so\n/// the caller can show a confirmation message without leaking the key.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum SavedCredential {\n /// Stored in **both** the OS keyring and the deepseek config file.\n /// This is the default outcome on platforms with a working keyring\n /// backend: writing both layers defeats the\n /// `keyring → env → config-file` resolution-order shadow that\n /// would otherwise let a stale OS-keyring entry from a previous\n /// install hide the freshly-entered key (#593). The `backend`\n /// label is the value of [`deepseek_secrets::Secrets::backend_name`]\n /// at write time so the toast text can name the actual backend\n /// (`\"system keyring\"`, `\"file-based (~/.deepseek/secrets/)\"`).\n KeyringAndConfigFile {\n /// `Secrets::backend_name()` at write time.\n backend: String,\n /// Absolute path to the config file that was also updated.\n path: PathBuf,\n },\n /// Stored in the deepseek config file only. Fallback when no\n /// keyring backend is reachable, or under `cfg(test)` so unit\n /// tests don't pollute the host keyring.\n ConfigFile(PathBuf),\n}\n\nimpl SavedCredential {\n /// Human-readable description for status / log output. Never\n /// includes the key value.\n #[must_use]\n pub fn describe(&self) -> String {\n match self {\n Self::KeyringAndConfigFile { backend, path } => {\n format!(\"OS keyring ({backend}) and {}\", path.display())\n }\n Self::ConfigFile(path) => path.display().to_string(),\n }\n }\n}\n\n/// Save the active provider's API key.\n///\n/// **Dual-write strategy (#593):** writes to `~/.deepseek/config.toml`\n/// (always) and to the OS keyring via [`deepseek_secrets::Secrets`]\n/// (when a backend is reachable). The runtime resolves credentials in\n/// `keyring → env → config-file` order; writing to the config file\n/// alone — as v0.8.8 through v0.8.10 did — let a stale keyring entry\n/// from a prior install silently shadow the fresh value the user just\n/// typed during in-TUI onboarding, producing the \"no response\" symptom\n/// reported in #593.\n///\n/// The config file remains the inspectable durable record (works in\n/// npm installs, IDE terminals, and headless boxes alike), and the\n/// keyring acts as the layered override that defeats stale-shadow on\n/// the resolution path. When the keyring write fails (no backend, OS\n/// permission denied, etc.) the config-file write still stands and\n/// the function reports a [`SavedCredential::ConfigFile`] outcome —\n/// callers should not treat that as a failure.\n///\n/// Skipped under `cfg(test)` so the suite never touches the host\n/// keyring. The `secrets` crate has its own test coverage for\n/// keyring set/get.\npub fn save_api_key(api_key: &str) -> Result {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n anyhow::bail!(\"Refusing to save an empty API key.\");\n }\n\n // Always write the inspectable copy first. The config file is the\n // durable record everyone — including macOS Keychain-prompted\n // first-run, headless CI, and IDE terminals — can rely on.\n let path = save_api_key_to_config_file(trimmed)?;\n\n // Then mirror to the OS keyring when one is reachable. This\n // overwrites any stale entry from a prior install so\n // `Secrets::resolve` (keyring → env → config-file) no longer\n // shadows the fresh key. Skipped under `cfg(test)` so unit tests\n // can't pollute the host keyring (macOS Always-Allow prompts,\n // cross-test contamination).\n #[cfg(not(test))]\n {\n let secrets = deepseek_secrets::Secrets::auto_detect();\n match secrets.set(\"deepseek\", trimmed) {\n Ok(()) => {\n let backend = secrets.backend_name().to_string();\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": backend.clone(),\n \"config_path\": path.display().to_string(),\n \"dual_write\": true,\n }),\n );\n return Ok(SavedCredential::KeyringAndConfigFile { backend, path });\n }\n Err(err) => {\n tracing::warn!(\"OS keyring write failed; key saved to config.toml only: {err}\");\n // Fall through to the ConfigFile-only outcome below.\n }\n }\n }\n\n Ok(SavedCredential::ConfigFile(path))\n}\n\n/// Write the `api_key` slot directly to `config.toml`.\nfn save_api_key_to_config_file(api_key: &str) -> Result {\n fn is_api_key_assignment(line: &str) -> bool {\n let trimmed = line.trim_start();\n trimmed\n .strip_prefix(\"api_key\")\n .is_some_and(|rest| rest.trim_start().starts_with('='))\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n ensure_parent_dir(&config_path)?;\n\n let key_to_write = api_key.to_string();\n\n let content = if config_path.exists() {\n // Read existing config and update the api_key line\n let existing = fs::read_to_string(&config_path)?;\n if existing.contains(\"api_key\") {\n // Replace existing api_key line\n let mut result = String::new();\n for line in existing.lines() {\n if is_api_key_assignment(line) {\n let _ = writeln!(result, \"api_key = \\\"{key_to_write}\\\"\");\n } else {\n result.push_str(line);\n result.push('\\n');\n }\n }\n result\n } else {\n // Prepend api_key to existing config\n format!(\"api_key = \\\"{key_to_write}\\\"\\n{existing}\")\n }\n } else {\n // Create new minimal config\n format!(\n r#\"# DeepSeek TUI Configuration\n# Get your API key from https://platform.deepseek.com\n# Or set DEEPSEEK_API_KEY environment variable\n\napi_key = \"{key_to_write}\"\n\n# Base URL (default: https://api.deepseek.com/beta)\n# Set https://api.deepseek.com to opt out of beta features.\n# base_url = \"https://api.deepseek.com/beta\"\n\n# Default model\ndefault_text_model = \"{default_model}\"\n\n# Thinking mode (DeepSeek V4 reasoning effort):\n# \"off\" | \"low\" | \"medium\" | \"high\" | \"max\"\n# Shift+Tab in the TUI cycles between off / high / max.\nreasoning_effort = \"max\"\n\"#,\n default_model = DEFAULT_TEXT_MODEL\n )\n };\n\n write_config_file_secure(&config_path, &content)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Check if the active provider has any API key configured anywhere the\n/// runtime can resolve it.\n///\n/// Platform credential stores are intentionally not queried here.\n/// Startup/onboarding checks must be cheap and prompt-free, so v0.8.8\n/// keeps the default auth path to environment variables and\n/// `~/.deepseek/config.toml`.\n///\n/// Used by [`crate::tui::app::App::new`] to decide whether to gate\n/// the user behind the in-TUI api-key onboarding screen — getting\n/// this wrong made users get prompted for credentials in situations\n/// where normal env/config auth was already available.\npub fn has_api_key(config: &Config) -> bool {\n has_api_key_for(config, config.api_provider())\n}\n\n#[must_use]\npub fn active_provider_has_config_api_key(config: &Config) -> bool {\n let provider = config.api_provider();\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n}\n\n#[must_use]\npub fn active_provider_has_env_api_key(config: &Config) -> bool {\n match config.api_provider() {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n std::env::var(\"DEEPSEEK_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::NvidiaNim => {\n std::env::var(\"NVIDIA_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n || std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Openai => std::env::var(\"OPENAI_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Openrouter => {\n std::env::var(\"OPENROUTER_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Novita => std::env::var(\"NOVITA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Fireworks => {\n std::env::var(\"FIREWORKS_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n }\n ApiProvider::Sglang => std::env::var(\"SGLANG_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Vllm => std::env::var(\"VLLM_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n ApiProvider::Ollama => std::env::var(\"OLLAMA_API_KEY\").is_ok_and(|k| !k.trim().is_empty()),\n }\n}\n\n#[must_use]\npub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {\n active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)\n}\n\n/// Check whether the given provider has any usable API key — via env var,\n/// provider/root config. Used by the `/provider` picker to decide whether to\n/// prompt for a key inline.\n#[must_use]\npub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {\n let env_var = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => \"DEEPSEEK_API_KEY\",\n ApiProvider::NvidiaNim => \"NVIDIA_API_KEY\",\n ApiProvider::Openai => \"OPENAI_API_KEY\",\n ApiProvider::Openrouter => \"OPENROUTER_API_KEY\",\n ApiProvider::Novita => \"NOVITA_API_KEY\",\n ApiProvider::Fireworks => \"FIREWORKS_API_KEY\",\n ApiProvider::Sglang => \"SGLANG_API_KEY\",\n ApiProvider::Vllm => \"VLLM_API_KEY\",\n ApiProvider::Ollama => \"OLLAMA_API_KEY\",\n };\n if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) {\n return true;\n }\n if matches!(provider, ApiProvider::NvidiaNim)\n && std::env::var(\"NVIDIA_NIM_API_KEY\").is_ok_and(|k| !k.trim().is_empty())\n {\n return true;\n }\n\n // Self-hosted providers typically run without authentication.\n if matches!(\n provider,\n ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama\n ) {\n return true;\n }\n\n if config\n .provider_config_for(provider)\n .and_then(|entry| entry.api_key.as_ref())\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)\n && config\n .api_key\n .as_ref()\n .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)\n {\n return true;\n }\n\n false\n}\n\n/// Save an API key to the appropriate place for the given provider.\n/// DeepSeek goes through [`save_api_key`]. Other providers write\n/// `[providers.] api_key = \"...\"` to `~/.deepseek/config.toml`.\n/// Returns the config file path.\npub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result {\n if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {\n return match save_api_key(api_key)? {\n SavedCredential::KeyringAndConfigFile { path, .. }\n | SavedCredential::ConfigFile(path) => Ok(path),\n };\n }\n\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n ensure_parent_dir(&config_path)?;\n\n let table_name = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"providers.nvidia_nim\",\n ApiProvider::Openai => \"providers.openai\",\n ApiProvider::Openrouter => \"providers.openrouter\",\n ApiProvider::Novita => \"providers.novita\",\n ApiProvider::Fireworks => \"providers.fireworks\",\n ApiProvider::Sglang => \"providers.sglang\",\n ApiProvider::Vllm => \"providers.vllm\",\n ApiProvider::Ollama => \"providers.ollama\",\n };\n\n // Parse existing TOML (or start fresh) so we can edit the right table\n // without disturbing other sections.\n let mut doc: toml::Value = if config_path.exists() {\n let raw = fs::read_to_string(&config_path)?;\n toml::from_str(&raw)\n .with_context(|| format!(\"Failed to parse config at {}\", config_path.display()))?\n } else {\n toml::Value::Table(toml::value::Table::new())\n };\n\n let table = doc\n .as_table_mut()\n .context(\"Config root must be a TOML table.\")?;\n let providers = table\n .entry(\"providers\".to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .context(\"`providers` must be a table.\")?;\n let key_inside = match provider {\n ApiProvider::Deepseek | ApiProvider::DeepseekCN => {\n return Err(anyhow::anyhow!(\n \"save_api_key_for: DeepSeek variants must use the root api_key field, not provider-specific storage\"\n ));\n }\n ApiProvider::NvidiaNim => \"nvidia_nim\",\n ApiProvider::Openai => \"openai\",\n ApiProvider::Openrouter => \"openrouter\",\n ApiProvider::Novita => \"novita\",\n ApiProvider::Fireworks => \"fireworks\",\n ApiProvider::Sglang => \"sglang\",\n ApiProvider::Vllm => \"vllm\",\n ApiProvider::Ollama => \"ollama\",\n };\n let entry = providers\n .entry(key_inside.to_string())\n .or_insert_with(|| toml::Value::Table(toml::value::Table::new()))\n .as_table_mut()\n .with_context(|| format!(\"`{table_name}` must be a table.\"))?;\n entry.insert(\n \"api_key\".to_string(),\n toml::Value::String(api_key.to_string()),\n );\n\n let serialized = toml::to_string_pretty(&doc).context(\"failed to serialize updated config\")?;\n write_config_file_secure(&config_path, &serialized)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.save\",\n json!({\n \"backend\": \"config_file\",\n \"provider\": provider.as_str(),\n \"config_path\": config_path.display().to_string(),\n }),\n );\n\n Ok(config_path)\n}\n\n/// Clear the API key from config-file storage.\n///\n/// `/logout` calls this to wipe credentials so the next request can't\n/// silently use a stale config key (#343). The function strips the legacy\n/// root `api_key = ...` line *and* every `api_key` line nested in a\n/// `[providers.]` table.\n///\n/// Environment variables (`DEEPSEEK_API_KEY`, etc.) are intentionally\n/// **not** unset — they are managed by the user's shell and outside the\n/// CLI's purview. `Config::deepseek_api_key`'s explicit-override path\n/// (Path 0) ensures a freshly-entered key still wins over a stale env\n/// var that lingers from a previous session.\npub fn clear_api_key() -> Result<()> {\n // Strip api_key lines from config.toml, including provider-scoped nested\n // entries. Clearing a config file must not trigger platform credential\n // prompts.\n let config_path = default_config_path()\n .context(\"Failed to resolve config path: home directory not found.\")?;\n\n if !config_path.exists() {\n return Ok(());\n }\n\n let existing = fs::read_to_string(&config_path)?;\n let mut result = String::new();\n\n for line in existing.lines() {\n // Match `api_key`, `api_key =`, ` api_key=`, etc. — anywhere it\n // appears as the leading non-whitespace token.\n let trimmed = line.trim_start();\n if trimmed.strip_prefix(\"api_key\").is_some_and(|rest| {\n let rest = rest.trim_start();\n rest.is_empty() || rest.starts_with('=')\n }) {\n continue;\n }\n result.push_str(line);\n result.push('\\n');\n }\n\n write_config_file_secure(&config_path, &result)\n .with_context(|| format!(\"Failed to write config to {}\", config_path.display()))?;\n log_sensitive_event(\n \"credential.clear\",\n json!({\n \"backend\": \"config_file\",\n \"config_path\": config_path.display().to_string(),\n \"scope\": \"root_and_provider_keys\",\n }),\n );\n\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::lock_test_env;\n use std::collections::HashMap;\n use std::env;\n use std::ffi::OsString;\n #[cfg(unix)]\n use std::os::unix::fs::PermissionsExt;\n use std::time::{SystemTime, UNIX_EPOCH};\n\n // GHSA-72w5-pf8h-xfp4 — regression: `allow_shell` must be opt-in.\n #[test]\n fn allow_shell_defaults_to_false_when_unset() {\n let config = Config::default();\n assert_eq!(config.allow_shell, None, \"default Config has no opt-in set\");\n assert!(\n !config.allow_shell(),\n \"Config::allow_shell() must default to false when no opt-in is recorded\"\n );\n }\n\n #[test]\n fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {\n let policy: NetworkPolicyToml = toml::from_str(\n r#\"\n default = \"allow\"\n proxy = [\"github.com\", \".githubusercontent.com\"]\n \"#,\n )\n .expect(\"network policy toml\");\n\n let runtime = policy.into_runtime();\n\n assert_eq!(runtime.proxy, [\"github.com\", \".githubusercontent.com\"]);\n assert!(runtime.trusts_proxy_fakeip_host(\"github.com\"));\n assert!(runtime.trusts_proxy_fakeip_host(\"raw.githubusercontent.com\"));\n }\n\n struct EnvGuard {\n home: Option,\n userprofile: Option,\n deepseek_config_path: Option,\n deepseek_provider: Option,\n deepseek_api_key: Option,\n deepseek_base_url: Option,\n deepseek_http_headers: Option,\n deepseek_model: Option,\n deepseek_default_text_model: Option,\n nvidia_api_key: Option,\n nvidia_nim_api_key: Option,\n nim_base_url: Option,\n nvidia_base_url: Option,\n nvidia_nim_base_url: Option,\n nvidia_nim_model: Option,\n openai_api_key: Option,\n openai_base_url: Option,\n openai_model: Option,\n openrouter_api_key: Option,\n openrouter_base_url: Option,\n novita_api_key: Option,\n novita_base_url: Option,\n fireworks_api_key: Option,\n fireworks_base_url: Option,\n sglang_api_key: Option,\n sglang_base_url: Option,\n sglang_model: Option,\n vllm_api_key: Option,\n vllm_base_url: Option,\n vllm_model: Option,\n ollama_api_key: Option,\n ollama_base_url: Option,\n ollama_model: Option,\n }\n\n impl EnvGuard {\n fn new(home: &Path) -> Self {\n let home_str = OsString::from(home.as_os_str());\n let config_path = home.join(\".deepseek\").join(\"config.toml\");\n let config_str = OsString::from(config_path.as_os_str());\n let home_prev = env::var_os(\"HOME\");\n let userprofile_prev = env::var_os(\"USERPROFILE\");\n let deepseek_config_prev = env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n let deepseek_provider_prev = env::var_os(\"DEEPSEEK_PROVIDER\");\n let api_key_prev = env::var_os(\"DEEPSEEK_API_KEY\");\n let base_url_prev = env::var_os(\"DEEPSEEK_BASE_URL\");\n let http_headers_prev = env::var_os(\"DEEPSEEK_HTTP_HEADERS\");\n let model_prev = env::var_os(\"DEEPSEEK_MODEL\");\n let default_text_model_prev = env::var_os(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n let nvidia_api_key_prev = env::var_os(\"NVIDIA_API_KEY\");\n let nvidia_nim_api_key_prev = env::var_os(\"NVIDIA_NIM_API_KEY\");\n let nim_base_url_prev = env::var_os(\"NIM_BASE_URL\");\n let nvidia_base_url_prev = env::var_os(\"NVIDIA_BASE_URL\");\n let nvidia_nim_base_url_prev = env::var_os(\"NVIDIA_NIM_BASE_URL\");\n let nvidia_nim_model_prev = env::var_os(\"NVIDIA_NIM_MODEL\");\n let openai_api_key_prev = env::var_os(\"OPENAI_API_KEY\");\n let openai_base_url_prev = env::var_os(\"OPENAI_BASE_URL\");\n let openai_model_prev = env::var_os(\"OPENAI_MODEL\");\n let openrouter_api_key_prev = env::var_os(\"OPENROUTER_API_KEY\");\n let openrouter_base_url_prev = env::var_os(\"OPENROUTER_BASE_URL\");\n let novita_api_key_prev = env::var_os(\"NOVITA_API_KEY\");\n let novita_base_url_prev = env::var_os(\"NOVITA_BASE_URL\");\n let fireworks_api_key_prev = env::var_os(\"FIREWORKS_API_KEY\");\n let fireworks_base_url_prev = env::var_os(\"FIREWORKS_BASE_URL\");\n let sglang_api_key_prev = env::var_os(\"SGLANG_API_KEY\");\n let sglang_base_url_prev = env::var_os(\"SGLANG_BASE_URL\");\n let sglang_model_prev = env::var_os(\"SGLANG_MODEL\");\n let vllm_api_key_prev = env::var_os(\"VLLM_API_KEY\");\n let vllm_base_url_prev = env::var_os(\"VLLM_BASE_URL\");\n let vllm_model_prev = env::var_os(\"VLLM_MODEL\");\n let ollama_api_key_prev = env::var_os(\"OLLAMA_API_KEY\");\n let ollama_base_url_prev = env::var_os(\"OLLAMA_BASE_URL\");\n let ollama_model_prev = env::var_os(\"OLLAMA_MODEL\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"HOME\", &home_str);\n env::set_var(\"USERPROFILE\", &home_str);\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_str);\n env::remove_var(\"DEEPSEEK_PROVIDER\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_BASE_URL\");\n env::remove_var(\"DEEPSEEK_HTTP_HEADERS\");\n env::remove_var(\"DEEPSEEK_MODEL\");\n env::remove_var(\"DEEPSEEK_DEFAULT_TEXT_MODEL\");\n env::remove_var(\"NVIDIA_API_KEY\");\n env::remove_var(\"NVIDIA_NIM_API_KEY\");\n env::remove_var(\"NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_BASE_URL\");\n env::remove_var(\"NVIDIA_NIM_MODEL\");\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_BASE_URL\");\n env::remove_var(\"OPENAI_MODEL\");\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENROUTER_BASE_URL\");\n env::remove_var(\"NOVITA_API_KEY\");\n env::remove_var(\"NOVITA_BASE_URL\");\n env::remove_var(\"FIREWORKS_API_KEY\");\n env::remove_var(\"FIREWORKS_BASE_URL\");\n env::remove_var(\"SGLANG_API_KEY\");\n env::remove_var(\"SGLANG_BASE_URL\");\n env::remove_var(\"SGLANG_MODEL\");\n env::remove_var(\"VLLM_API_KEY\");\n env::remove_var(\"VLLM_BASE_URL\");\n env::remove_var(\"VLLM_MODEL\");\n env::remove_var(\"OLLAMA_API_KEY\");\n env::remove_var(\"OLLAMA_BASE_URL\");\n env::remove_var(\"OLLAMA_MODEL\");\n }\n Self {\n home: home_prev,\n userprofile: userprofile_prev,\n deepseek_config_path: deepseek_config_prev,\n deepseek_provider: deepseek_provider_prev,\n deepseek_api_key: api_key_prev,\n deepseek_base_url: base_url_prev,\n deepseek_http_headers: http_headers_prev,\n deepseek_model: model_prev,\n deepseek_default_text_model: default_text_model_prev,\n nvidia_api_key: nvidia_api_key_prev,\n nvidia_nim_api_key: nvidia_nim_api_key_prev,\n nim_base_url: nim_base_url_prev,\n nvidia_base_url: nvidia_base_url_prev,\n nvidia_nim_base_url: nvidia_nim_base_url_prev,\n nvidia_nim_model: nvidia_nim_model_prev,\n openai_api_key: openai_api_key_prev,\n openai_base_url: openai_base_url_prev,\n openai_model: openai_model_prev,\n openrouter_api_key: openrouter_api_key_prev,\n openrouter_base_url: openrouter_base_url_prev,\n novita_api_key: novita_api_key_prev,\n novita_base_url: novita_base_url_prev,\n fireworks_api_key: fireworks_api_key_prev,\n fireworks_base_url: fireworks_base_url_prev,\n sglang_api_key: sglang_api_key_prev,\n sglang_base_url: sglang_base_url_prev,\n sglang_model: sglang_model_prev,\n vllm_api_key: vllm_api_key_prev,\n vllm_base_url: vllm_base_url_prev,\n vllm_model: vllm_model_prev,\n ollama_api_key: ollama_api_key_prev,\n ollama_base_url: ollama_base_url_prev,\n ollama_model: ollama_model_prev,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n Self::restore_var(\"HOME\", self.home.take());\n Self::restore_var(\"USERPROFILE\", self.userprofile.take());\n Self::restore_var(\"DEEPSEEK_CONFIG_PATH\", self.deepseek_config_path.take());\n Self::restore_var(\"DEEPSEEK_PROVIDER\", self.deepseek_provider.take());\n Self::restore_var(\"DEEPSEEK_API_KEY\", self.deepseek_api_key.take());\n Self::restore_var(\"DEEPSEEK_BASE_URL\", self.deepseek_base_url.take());\n Self::restore_var(\"DEEPSEEK_HTTP_HEADERS\", self.deepseek_http_headers.take());\n Self::restore_var(\"DEEPSEEK_MODEL\", self.deepseek_model.take());\n Self::restore_var(\n \"DEEPSEEK_DEFAULT_TEXT_MODEL\",\n self.deepseek_default_text_model.take(),\n );\n Self::restore_var(\"NVIDIA_API_KEY\", self.nvidia_api_key.take());\n Self::restore_var(\"NVIDIA_NIM_API_KEY\", self.nvidia_nim_api_key.take());\n Self::restore_var(\"NIM_BASE_URL\", self.nim_base_url.take());\n Self::restore_var(\"NVIDIA_BASE_URL\", self.nvidia_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_BASE_URL\", self.nvidia_nim_base_url.take());\n Self::restore_var(\"NVIDIA_NIM_MODEL\", self.nvidia_nim_model.take());\n Self::restore_var(\"OPENAI_API_KEY\", self.openai_api_key.take());\n Self::restore_var(\"OPENAI_BASE_URL\", self.openai_base_url.take());\n Self::restore_var(\"OPENAI_MODEL\", self.openai_model.take());\n Self::restore_var(\"OPENROUTER_API_KEY\", self.openrouter_api_key.take());\n Self::restore_var(\"OPENROUTER_BASE_URL\", self.openrouter_base_url.take());\n Self::restore_var(\"NOVITA_API_KEY\", self.novita_api_key.take());\n Self::restore_var(\"NOVITA_BASE_URL\", self.novita_base_url.take());\n Self::restore_var(\"FIREWORKS_API_KEY\", self.fireworks_api_key.take());\n Self::restore_var(\"FIREWORKS_BASE_URL\", self.fireworks_base_url.take());\n Self::restore_var(\"SGLANG_API_KEY\", self.sglang_api_key.take());\n Self::restore_var(\"SGLANG_BASE_URL\", self.sglang_base_url.take());\n Self::restore_var(\"SGLANG_MODEL\", self.sglang_model.take());\n Self::restore_var(\"VLLM_API_KEY\", self.vllm_api_key.take());\n Self::restore_var(\"VLLM_BASE_URL\", self.vllm_base_url.take());\n Self::restore_var(\"VLLM_MODEL\", self.vllm_model.take());\n Self::restore_var(\"OLLAMA_API_KEY\", self.ollama_api_key.take());\n Self::restore_var(\"OLLAMA_BASE_URL\", self.ollama_base_url.take());\n Self::restore_var(\"OLLAMA_MODEL\", self.ollama_model.take());\n }\n }\n }\n\n impl EnvGuard {\n /// Restore an env var to its prior value (or remove it if it was unset).\n ///\n /// # Safety\n /// Must only be called from test code guarded by a global mutex.\n unsafe fn restore_var(key: &str, prev: Option) {\n if let Some(value) = prev {\n unsafe { env::set_var(key, value) };\n } else {\n unsafe { env::remove_var(key) };\n }\n }\n }\n\n #[test]\n fn max_subagents_defaults_to_ten() {\n assert_eq!(Config::default().max_subagents(), DEFAULT_MAX_SUBAGENTS);\n assert_eq!(DEFAULT_MAX_SUBAGENTS, 10);\n }\n\n #[test]\n fn subagents_max_concurrent_overrides_top_level_cap() {\n let config = Config {\n max_subagents: Some(3),\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(12),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n\n assert_eq!(config.max_subagents(), 12);\n }\n\n #[test]\n fn max_subagents_clamps_subagents_max_concurrent() {\n let low = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(0),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(low.max_subagents(), 1);\n\n let high = Config {\n subagents: Some(SubagentsConfig {\n max_concurrent: Some(MAX_SUBAGENTS + 10),\n ..SubagentsConfig::default()\n }),\n ..Config::default()\n };\n assert_eq!(high.max_subagents(), MAX_SUBAGENTS);\n }\n\n #[test]\n fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {\n // `save_api_key` writes to the shared user config file. This\n // pins the boring v0.8.8 setup path and avoids platform\n // credential prompts during onboarding.\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let saved = save_api_key(\"test-key\")?;\n let expected = temp_root.join(\".deepseek\").join(\"config.toml\");\n assert_eq!(saved, SavedCredential::ConfigFile(expected.clone()));\n assert_eq!(saved.describe(), expected.display().to_string());\n\n let contents = fs::read_to_string(&expected)?;\n assert!(contents.contains(\"api_key = \\\"\"));\n\n #[cfg(unix)]\n {\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n let parent = expected.parent().expect(\"config has parent dir\");\n assert_eq!(fs::metadata(parent)?.permissions().mode() & 0o077, 0);\n\n fs::set_permissions(&expected, fs::Permissions::from_mode(0o644))?;\n save_api_key(\"second-test-key\")?;\n assert_eq!(fs::metadata(&expected)?.permissions().mode() & 0o777, 0o600);\n }\n Ok(())\n }\n\n #[test]\n fn ensure_config_file_exists_creates_first_run_template() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-first-run-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let created = ensure_config_file_exists(None)?.expect(\"should create config\");\n let content = fs::read_to_string(&created)?;\n\n assert_eq!(created, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(content.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n assert!(content.contains(\"reasoning_effort = \\\"auto\\\"\"));\n assert!(!content.contains(\"api_key =\"));\n assert!(ensure_config_file_exists(None)?.is_none());\n Ok(())\n }\n\n #[test]\n fn workspace_trust_round_trips_through_global_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-workspace-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n\n assert!(!is_workspace_trusted(&workspace));\n let saved = save_workspace_trust(&workspace)?;\n\n assert_eq!(saved, temp_root.join(\".deepseek\").join(\"config.toml\"));\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n assert!(\n !workspace.join(\".deepseek\").exists(),\n \"trust persistence must not create a project-local .deepseek directory\"\n );\n\n let parsed: toml::Value = toml::from_str(&fs::read_to_string(saved)?)?;\n assert_eq!(\n workspace_trust_level_from_doc(&parsed, &workspace),\n Some(\"trusted\")\n );\n Ok(())\n }\n\n #[test]\n fn workspace_trust_reads_existing_projects_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-existing-project-trust-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n let workspace = temp_root.join(\"project\");\n fs::create_dir_all(&workspace)?;\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n fs::create_dir_all(config_path.parent().unwrap())?;\n fs::write(\n &config_path,\n format!(\n \"[projects.\\\"{}\\\"]\\ntrust_level = \\\"trusted\\\"\\n\",\n workspace_config_key(&workspace)\n .replace('\\\\', \"\\\\\\\\\")\n .replace('\"', \"\\\\\\\"\")\n ),\n )?;\n\n assert!(is_workspace_trusted(&workspace));\n assert!(!crate::tui::onboarding::needs_trust(&workspace));\n Ok(())\n }\n\n #[test]\n fn save_api_key_rejects_empty_input() {\n let _lock = lock_test_env();\n let err = save_api_key(\" \").expect_err(\"empty should bail\");\n assert!(\n err.to_string().contains(\"empty\"),\n \"expected error to mention empty, got: {err}\"\n );\n }\n\n #[test]\n fn saved_credential_describe_returns_config_file_path() {\n let cf = SavedCredential::ConfigFile(PathBuf::from(\"/tmp/x.toml\"));\n assert_eq!(cf.describe(), \"/tmp/x.toml\");\n }\n\n /// #593: the dual-write outcome describes both targets so the\n /// onboarding toast (`API key saved to {describe}`) tells the user\n /// the key landed in *both* the keyring and the config file —\n /// which is the whole point of the fix (defeats stale-keyring\n /// shadow while keeping the config file inspectable).\n #[test]\n fn saved_credential_describe_lists_both_targets_for_keyring_and_config() {\n let dual = SavedCredential::KeyringAndConfigFile {\n backend: \"system keyring\".to_string(),\n path: PathBuf::from(\"/tmp/x.toml\"),\n };\n assert_eq!(\n dual.describe(),\n \"OS keyring (system keyring) and /tmp/x.toml\"\n );\n }\n\n #[test]\n fn has_api_key_detects_in_memory_override_and_env_var() -> Result<()> {\n // Pins the v0.8.8 contract: `has_api_key` covers the prompt-free\n // sources used by `Config::deepseek_api_key` (in-memory override,\n // env var, config-file slot).\n let _lock = lock_test_env();\n // Explicit in-memory key wins over every other source per\n // `Config::deepseek_api_key`'s \"Path 0\" override.\n let cfg = Config {\n api_key: Some(\"sk-in-memory-override\".to_string()),\n ..Default::default()\n };\n assert!(\n has_api_key(&cfg),\n \"in-memory override must be detected as a usable key\"\n );\n\n // Env var path.\n let env_cfg = Config::default();\n unsafe {\n std::env::set_var(\"DEEPSEEK_API_KEY\", \"sk-test-from-env\");\n }\n assert!(\n has_api_key(&env_cfg),\n \"env-var key must be detected even with empty config\"\n );\n unsafe {\n std::env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n fn config_with_provider_scoped_key(provider: &str, api_key: &str) -> Config {\n let mut providers = ProvidersConfig::default();\n match provider {\n \"deepseek\" | \"deepseek-cn\" => {\n providers.deepseek.api_key = Some(api_key.to_string());\n }\n \"nvidia-nim\" => {\n providers.nvidia_nim.api_key = Some(api_key.to_string());\n }\n \"openai\" => {\n providers.openai.api_key = Some(api_key.to_string());\n }\n \"openrouter\" => {\n providers.openrouter.api_key = Some(api_key.to_string());\n }\n \"novita\" => {\n providers.novita.api_key = Some(api_key.to_string());\n }\n \"fireworks\" => {\n providers.fireworks.api_key = Some(api_key.to_string());\n }\n \"sglang\" => {\n providers.sglang.api_key = Some(api_key.to_string());\n }\n \"vllm\" => {\n providers.vllm.api_key = Some(api_key.to_string());\n }\n \"ollama\" => {\n providers.ollama.api_key = Some(api_key.to_string());\n }\n _ => panic!(\"unexpected provider {provider}\"),\n }\n\n Config {\n provider: Some(provider.to_string()),\n providers: Some(providers),\n ..Config::default()\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_scoped_config_key() {\n for provider in [\"openai\", \"openrouter\", \"novita\", \"fireworks\"] {\n let config = config_with_provider_scoped_key(provider, \"provider-config-key\");\n\n assert!(\n has_api_key(&config),\n \"active provider config key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n #[test]\n fn has_api_key_uses_active_provider_env_key() -> Result<()> {\n let _lock = lock_test_env();\n for (provider, env_var) in [\n (\"openai\", \"OPENAI_API_KEY\"),\n (\"openrouter\", \"OPENROUTER_API_KEY\"),\n (\"novita\", \"NOVITA_API_KEY\"),\n (\"fireworks\", \"FIREWORKS_API_KEY\"),\n ] {\n unsafe {\n std::env::set_var(env_var, \"provider-env-key\");\n }\n\n let config = Config {\n provider: Some(provider.to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"active provider env key must satisfy onboarding auth check for {provider}\"\n );\n\n unsafe {\n std::env::remove_var(env_var);\n }\n }\n Ok(())\n }\n\n #[test]\n fn has_api_key_uses_root_config_key_for_deepseek_variants() {\n for provider in [\"deepseek\", \"deepseek-cn\"] {\n let config = Config {\n provider: Some(provider.to_string()),\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(\n has_api_key(&config),\n \"root config api_key must satisfy onboarding auth check for {provider}\"\n );\n }\n }\n\n /// Regression for #343: clear_api_key strips both the root `api_key`\n /// and any nested `[providers.].api_key` lines from config.toml\n /// so a stale credential can't shadow a fresh login.\n #[test]\n fn clear_api_key_strips_root_and_provider_scoped_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-clear-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_dir = temp_root.join(\".deepseek\");\n fs::create_dir_all(&config_dir)?;\n let config_path = config_dir.join(\"config.toml\");\n fs::write(\n &config_path,\n r#\"api_key = \"old-root-key\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.deepseek]\napi_key = \"old-provider-key\"\nbase_url = \"https://api.deepseek.com\"\n\n[providers.openrouter]\napi_key = \"old-openrouter-key\"\n\"#,\n )?;\n\n clear_api_key()?;\n\n let after = fs::read_to_string(&config_path)?;\n assert!(\n !after.contains(\"old-root-key\"),\n \"root api_key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-provider-key\"),\n \"provider-scoped deepseek key must be stripped: {after}\"\n );\n assert!(\n !after.contains(\"old-openrouter-key\"),\n \"provider-scoped openrouter key must be stripped: {after}\"\n );\n // Non-credential lines must survive.\n assert!(after.contains(\"default_text_model\"));\n assert!(after.contains(\"base_url\"));\n Ok(())\n }\n\n /// Regression for #343: explicit in-memory `api_key` (non-empty,\n /// non-sentinel) wins over env/config so a freshly-typed onboarding\n /// key takes effect immediately.\n #[test]\n fn deepseek_api_key_prefers_explicit_in_memory_override() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-override-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(\"freshly-typed-key\".to_string()),\n ..Config::default()\n };\n let resolved = config\n .deepseek_api_key()\n .expect(\"explicit override must resolve\");\n assert_eq!(resolved, \"freshly-typed-key\");\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_prefers_saved_config_over_stale_env() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-config-over-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"stale-env-key\");\n }\n let config = Config {\n api_key: Some(\"fresh-config-key\".to_string()),\n ..Config::default()\n };\n assert_eq!(config.deepseek_api_key()?, \"fresh-config-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn active_provider_detects_env_only_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let temp_root =\n env::temp_dir().join(format!(\"deepseek-tui-env-only-key-{}\", std::process::id()));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-only-key\");\n }\n let mut config = Config::default();\n assert!(active_provider_has_env_api_key(&config));\n assert!(!active_provider_has_config_api_key(&config));\n assert!(active_provider_uses_env_only_api_key(&config));\n\n config.api_key = Some(\"config-key\".to_string());\n assert!(active_provider_has_config_api_key(&config));\n assert!(!active_provider_uses_env_only_api_key(&config));\n\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn deepseek_api_key_ignores_sentinel_placeholder() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sentinel-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n api_key: Some(API_KEYRING_SENTINEL.to_string()),\n ..Config::default()\n };\n // Sentinel must not be treated as a real key — the resolver should\n // fall through to env / config-provider and ultimately bail out\n // with a \"key not found\" error.\n let _err = config\n .deepseek_api_key()\n .expect_err(\"sentinel placeholder must not satisfy the API key check\");\n Ok(())\n }\n\n #[test]\n fn test_tilde_expansion_in_paths() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n skills_dir: Some(\"~/.deepseek/skills\".to_string()),\n ..Default::default()\n };\n let expected_skills = temp_root.join(\".deepseek\").join(\"skills\");\n let actual_skills = config.skills_dir();\n assert_eq!(\n actual_skills.components().collect::>(),\n expected_skills.components().collect::>()\n );\n\n Ok(())\n }\n\n #[test]\n fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-tilde-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".custom-deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(&config_path, \"api_key = \\\"test-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_CONFIG_PATH\", \"~/.custom-deepseek/config.toml\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"test-key\"));\n Ok(())\n }\n\n #[test]\n fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-load-fallback-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let home_config = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&home_config)?;\n fs::write(&home_config, \"api_key = \\\"home-key\\\"\\n\")?;\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\n \"DEEPSEEK_CONFIG_PATH\",\n temp_root.join(\"missing-config.toml\").as_os_str(),\n );\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_key.as_deref(), Some(\"home-key\"));\n Ok(())\n }\n\n #[test]\n fn test_nonexistent_profile_error() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config::default(),\n profiles: Some(profiles),\n };\n\n let err = apply_profile(config, Some(\"nonexistent\")).unwrap_err();\n let message = err.to_string();\n assert!(message.contains(\"Profile 'nonexistent' not found\"));\n assert!(message.contains(\"Available profiles\"));\n assert!(message.contains(\"work\"));\n }\n\n #[test]\n fn test_profile_with_no_profiles_section() {\n let config = ConfigFile {\n base: Config::default(),\n profiles: None,\n };\n\n let err = apply_profile(config, Some(\"missing\")).unwrap_err();\n assert!(err.to_string().contains(\"Available profiles: none\"));\n }\n\n #[test]\n fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-api-key-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"api_key_backup = \\\"old\\\"\\napi_key = \\\"current\\\"\\n\",\n )?;\n\n let saved = save_api_key(\"new-key\")?;\n assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));\n\n let contents = fs::read_to_string(&config_path)?;\n assert!(contents.contains(\"api_key_backup = \\\"old\\\"\"));\n assert!(contents.contains(\"api_key = \\\"\"));\n Ok(())\n }\n\n #[test]\n fn test_empty_api_key_rejected() {\n let config = Config {\n api_key: Some(\" \".to_string()),\n ..Default::default()\n };\n assert!(config.validate().is_err());\n }\n\n #[test]\n fn test_missing_api_key_allowed() -> Result<()> {\n let config = Config::default();\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-empty-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Simulate a fresh user who copied .env.example to .env without\n // filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"\");\n }\n\n let mut config = Config {\n api_key: Some(\"from-config-file\".to_string()),\n ..Default::default()\n };\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key.as_deref(), Some(\"from-config-file\"));\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn apply_env_overrides_does_not_copy_api_key_into_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-env-key-not-config-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"env-key\");\n }\n let mut config = Config::default();\n apply_env_overrides(&mut config);\n\n assert_eq!(config.api_key, None);\n assert_eq!(config.deepseek_api_key()?, \"env-key\");\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n Ok(())\n }\n\n #[test]\n fn normalize_model_name_preserves_v_series_snapshots() {\n // v4 canonical forms still resolve\n assert_eq!(\n normalize_model_name(\"deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-v4pro\").as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n // v-series dated snapshots pass through unchanged\n assert_eq!(\n normalize_model_name(\"deepseek-v4-flash-20260423\").as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n // future v-series identities pass through\n assert_eq!(\n normalize_model_name(\"deepseek-v5-pro-20270101\").as_deref(),\n Some(\"deepseek-v5-pro-20270101\")\n );\n // legacy names pass through unchanged — server decides\n assert_eq!(\n normalize_model_name(\"deepseek-chat\").as_deref(),\n Some(\"deepseek-chat\")\n );\n // cross-provider names still normalize\n assert_eq!(\n normalize_model_name(\"deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"deepseek-ai/deepseek-v4-pro\")\n );\n // preserve exact case for providers that require case-sensitive model IDs\n assert_eq!(\n normalize_model_name(\"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_name(\"deepseek-ai/DeepSeek-V4-Pro\").as_deref(),\n Some(\"deepseek-ai/DeepSeek-V4-Pro\")\n );\n }\n\n #[test]\n fn normalize_model_for_provider_keeps_provider_remaps_when_case_is_preserved() {\n assert_eq!(\n normalize_model_for_provider(ApiProvider::Deepseek, \"DeepSeek-V4-Pro\").as_deref(),\n Some(\"DeepSeek-V4-Pro\")\n );\n assert_eq!(\n normalize_model_for_provider(ApiProvider::NvidiaNim, \"DeepSeek-V4-Pro\").as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n }\n\n #[test]\n fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() {\n assert!(normalize_model_name(\"gpt-4o\").is_none());\n assert!(normalize_model_name(\"deepseek v4\").is_none());\n assert!(normalize_model_name(\"\").is_none());\n }\n\n #[test]\n fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() {\n assert_eq!(\n normalize_model_name(\"accounts/fireworks/models/deepseek-v4-flash\").as_deref(),\n Some(\"accounts/fireworks/models/deepseek-v4-flash\")\n );\n assert_eq!(\n normalize_model_name(\"provider/deepseek-ai/deepseek-v4-pro\").as_deref(),\n Some(\"provider/deepseek-ai/deepseek-v4-pro\")\n );\n }\n\n #[test]\n fn default_context_seams_are_opt_in() {\n let config = Config::default();\n assert!(!config.context.enabled.unwrap_or(false));\n assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000);\n assert_eq!(config.context.cycle_threshold.unwrap_or(768_000), 768_000);\n assert_eq!(\n config\n .context\n .seam_model\n .as_deref()\n .unwrap_or(\"deepseek-v4-flash\"),\n \"deepseek-v4-flash\"\n );\n }\n\n #[test]\n fn profile_without_context_does_not_disable_base_context() {\n let mut profiles = HashMap::new();\n profiles.insert(\"work\".to_string(), Config::default());\n let config = ConfigFile {\n base: Config {\n context: ContextConfig {\n enabled: Some(true),\n ..Default::default()\n },\n ..Default::default()\n },\n profiles: Some(profiles),\n };\n\n let merged = apply_profile(config, Some(\"work\")).expect(\"profile\");\n assert_eq!(merged.context.enabled, Some(true));\n }\n\n #[test]\n fn removed_context_per_model_table_is_ignored_for_compatibility() -> Result<()> {\n let parsed: ConfigFile = toml::from_str(\n r#\"\n [context]\n enabled = true\n\n [context.per_model.deepseek-v4-pro]\n l1_threshold = 111\n l2_threshold = 222\n l3_threshold = 333\n cycle_threshold = 444\n \"#,\n )?;\n\n assert_eq!(parsed.base.context.enabled, Some(true));\n Ok(())\n }\n\n #[test]\n fn project_context_pack_defaults_on_and_can_be_disabled() {\n let mut config = Config::default();\n assert!(config.project_context_pack_enabled());\n\n config.context.project_pack = Some(false);\n assert!(!config.project_context_pack_enabled());\n }\n\n #[test]\n fn validate_accepts_future_deepseek_model_id() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"deepseek-v4\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n Ok(())\n }\n\n #[test]\n fn validate_accepts_auto_default_text_model() -> Result<()> {\n let config = Config {\n default_text_model: Some(\"auto\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.default_model(), \"auto\");\n Ok(())\n }\n\n #[test]\n fn deepseek_provider_defaults_to_beta_endpoint() {\n let config = Config::default();\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);\n }\n\n #[test]\n fn explicit_deepseek_base_url_overrides_beta_default() {\n let config = Config {\n base_url: Some(\"https://api.deepseek.com\".to_string()),\n ..Default::default()\n };\n\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n assert_eq!(config.deepseek_base_url(), \"https://api.deepseek.com\");\n }\n\n #[test]\n fn deepseek_model_env_overrides_default_text_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-model-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_MODEL\", \"deepseek-v4-flash-20260423\");\n }\n\n let config = Config::load(None, None)?;\n // v-series snapshots pass through unchanged — no alias folding\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(\"deepseek-v4-flash-20260423\")\n );\n Ok(())\n }\n\n #[test]\n fn http_headers_load_from_root_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-root-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"tongyi\" }\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"tongyi\")\n );\n Ok(())\n }\n\n #[test]\n fn provider_http_headers_extend_and_override_root_config() {\n let mut providers = ProvidersConfig::default();\n providers.deepseek.http_headers = Some(HashMap::from([\n (\"X-Model-Provider-Id\".to_string(), \"tongyi\".to_string()),\n (\"X-Shared\".to_string(), \"provider\".to_string()),\n ]));\n let config = Config {\n http_headers: Some(HashMap::from([\n (\"X-Root\".to_string(), \"root\".to_string()),\n (\"X-Shared\".to_string(), \"root\".to_string()),\n ])),\n providers: Some(providers),\n ..Default::default()\n };\n\n let headers = config.http_headers();\n assert_eq!(\n headers.get(\"X-Model-Provider-Id\").map(String::as_str),\n Some(\"tongyi\")\n );\n assert_eq!(headers.get(\"X-Root\").map(String::as_str), Some(\"root\"));\n assert_eq!(\n headers.get(\"X-Shared\").map(String::as_str),\n Some(\"provider\")\n );\n }\n\n #[test]\n fn http_headers_env_overrides_config() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-http-headers-env-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"\napi_key = \"test-key\"\nhttp_headers = { \"X-Model-Provider-Id\" = \"from-file\" }\n\"#,\n )?;\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_HTTP_HEADERS\", \"X-Model-Provider-Id=from-env\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(\n config\n .http_headers()\n .get(\"X-Model-Provider-Id\")\n .map(String::as_str),\n Some(\"from-env\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-model-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n \"provider = \\\"nvidia-nim\\\"\\ndefault_text_model = \\\"deepseek-v4-pro\\\"\\napi_key = \\\"nim-key\\\"\\n\",\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.default_text_model.as_deref(),\n Some(DEFAULT_NVIDIA_NIM_MODEL)\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {\n let config = Config {\n provider: Some(\"nvidia-nim\".to_string()),\n default_text_model: Some(\"deepseek-v4-flash\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NVIDIA_API_KEY\", \"nim-env-key\");\n env::set_var(\"NVIDIA_NIM_MODEL\", \"deepseek-ai/deepseek-v4-pro\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-env-key\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-base-url-alias-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"NIM_BASE_URL\", \"https://short-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_base_url(), \"https://short-nim.example/v1\");\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"nvidia-nim\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-nim.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-nim.example/v1\"\n );\n Ok(())\n }\n\n #[test]\n fn openai_provider_uses_openai_compatible_defaults() -> Result<()> {\n let config = Config {\n provider: Some(\"openai\".to_string()),\n ..Default::default()\n };\n\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.default_model(), DEFAULT_OPENAI_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENAI_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn openai_provider_accepts_custom_model_and_base_url() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openai\"\n\n[providers.openai]\napi_key = \"openai-table-key\"\nbase_url = \"https://openai-compatible.example/api/coding/paas/v4\"\nmodel = \"glm-5\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-table-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/api/coding/paas/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_overrides_provider_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env-key\");\n env::set_var(\"OPENAI_BASE_URL\", \"https://openai-compatible.example/v4\");\n env::set_var(\"OPENAI_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"openai-env-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://openai-compatible.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openai_env_accepts_facade_base_url_forwarding() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-openai-forwarded-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openai\");\n env::set_var(\"OPENAI_API_KEY\", \"forwarded-openai-key\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"https://forwarded-openai.example/v4\");\n env::set_var(\"DEEPSEEK_MODEL\", \"glm-5\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openai);\n assert_eq!(config.deepseek_api_key()?, \"forwarded-openai-key\");\n assert_eq!(\n config.deepseek_base_url(),\n \"https://forwarded-openai.example/v4\"\n );\n assert_eq!(config.default_model(), \"glm-5\");\n Ok(())\n }\n\n #[test]\n fn openrouter_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"openrouter\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.default_model(), DEFAULT_OPENROUTER_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OPENROUTER_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn novita_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"novita\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.default_model(), DEFAULT_NOVITA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn fireworks_provider_uses_canonical_defaults() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-fireworks-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"fireworks\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Fireworks);\n assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn sglang_provider_works_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-sglang-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"sglang\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Sglang);\n assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Sglang));\n Ok(())\n }\n\n #[test]\n fn ollama_provider_uses_local_defaults_without_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-defaults-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config = Config {\n provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n config.validate()?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), DEFAULT_OLLAMA_MODEL);\n assert_eq!(config.deepseek_base_url(), DEFAULT_OLLAMA_BASE_URL);\n assert_eq!(config.deepseek_api_key()?, \"\");\n assert!(has_api_key_for(&config, ApiProvider::Ollama));\n Ok(())\n }\n\n #[test]\n fn ollama_model_is_passed_through_verbatim() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-model-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"ollama\"\n\n[providers.ollama]\nbase_url = \"http://127.0.0.1:11434/v1\"\nmodel = \"qwen2.5-coder:7b\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.default_model(), \"qwen2.5-coder:7b\");\n assert_eq!(config.deepseek_base_url(), \"http://127.0.0.1:11434/v1\");\n Ok(())\n }\n\n #[test]\n fn deepseek_base_url_env_scopes_to_self_hosted_providers() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-self-hosted-base-url-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://ollama.remote:11434/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.remote:11434/v1\");\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"vllm\");\n env::set_var(\"DEEPSEEK_BASE_URL\", \"http://vllm.remote:8000/v1\");\n }\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Vllm);\n assert_eq!(config.deepseek_base_url(), \"http://vllm.remote:8000/v1\");\n Ok(())\n }\n\n #[test]\n fn ollama_env_overrides_base_url_and_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-ollama-env-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"ollama-local\");\n env::set_var(\"OLLAMA_BASE_URL\", \"http://ollama.example/v1\");\n env::set_var(\"OLLAMA_MODEL\", \"deepseek-coder-v2:16b\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n assert_eq!(config.deepseek_base_url(), \"http://ollama.example/v1\");\n assert_eq!(config.default_model(), \"deepseek-coder-v2:16b\");\n Ok(())\n }\n\n #[test]\n fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-env-key\");\n Ok(())\n }\n\n #[test]\n fn novita_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-env-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"novita\");\n env::set_var(\"NOVITA_API_KEY\", \"novita-env-key\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-env-key\");\n Ok(())\n }\n\n #[test]\n fn openrouter_base_url_env_overrides_default() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-base-url-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"DEEPSEEK_PROVIDER\", \"openrouter\");\n env::set_var(\"OPENROUTER_BASE_URL\", \"https://or-mirror.example/v1\");\n }\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_base_url(), \"https://or-mirror.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://or-table.example/v1\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://or-table.example/v1\");\n Ok(())\n }\n\n #[test]\n fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-or-custom-model-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"or-table-key\"\nbase_url = \"https://gateway.example.com/v1\"\nmodel = \"DeepSeek-V4-Pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Openrouter);\n assert_eq!(config.deepseek_api_key()?, \"or-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://gateway.example.com/v1\");\n assert_eq!(config.default_model(), \"DeepSeek-V4-Pro\");\n Ok(())\n }\n\n #[test]\n fn novita_reads_provider_table_from_config_file() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-novita-table-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"novita\"\n\n[providers.novita]\napi_key = \"novita-table-key\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::Novita);\n assert_eq!(config.deepseek_api_key()?, \"novita-table-key\");\n assert_eq!(config.deepseek_base_url(), DEFAULT_NOVITA_BASE_URL);\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut config = Config::default();\n assert!(!has_api_key_for(&config, ApiProvider::Openai));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(\n has_api_key_for(&config, ApiProvider::Sglang),\n \"SGLang is self-hosted and does not require a key by default\"\n );\n assert!(\n has_api_key_for(&config, ApiProvider::Vllm),\n \"vLLM is self-hosted and does not require a key by default\"\n );\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::set_var(\"OPENROUTER_API_KEY\", \"or-env\");\n env::set_var(\"OPENAI_API_KEY\", \"openai-env\");\n }\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Openrouter));\n assert!(!has_api_key_for(&config, ApiProvider::Novita));\n\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n env::remove_var(\"OPENROUTER_API_KEY\");\n env::remove_var(\"OPENAI_API_KEY\");\n }\n let mut providers = ProvidersConfig::default();\n providers.openai.api_key = Some(\"file-openai\".to_string());\n providers.novita.api_key = Some(\"file-novita\".to_string());\n config.providers = Some(providers);\n assert!(has_api_key_for(&config, ApiProvider::Openai));\n assert!(has_api_key_for(&config, ApiProvider::Novita));\n assert!(!has_api_key_for(&config, ApiProvider::Openrouter));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_deepseek_cn_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-has-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let mut providers = ProvidersConfig::default();\n providers.deepseek_cn.api_key = Some(\"cn-file-key\".to_string());\n let config = Config {\n providers: Some(providers),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n Ok(())\n }\n\n #[test]\n fn has_api_key_for_uses_root_config_key_for_deepseek_variants() {\n let config = Config {\n api_key: Some(\"root-config-key\".to_string()),\n ..Config::default()\n };\n\n assert!(has_api_key_for(&config, ApiProvider::Deepseek));\n assert!(has_api_key_for(&config, ApiProvider::DeepseekCN));\n }\n\n #[test]\n fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-or-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::Openrouter, \"or-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n // Re-saving must not duplicate or wipe sibling tables.\n save_api_key_for(ApiProvider::Novita, \"novita-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openrouter\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"or-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"novita\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"novita-saved-key\")\n );\n save_api_key_for(ApiProvider::Openai, \"openai-saved-key\")?;\n save_api_key_for(ApiProvider::Fireworks, \"fireworks-saved-key\")?;\n save_api_key_for(ApiProvider::Sglang, \"sglang-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"openai\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"openai-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"fireworks\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"fireworks-saved-key\")\n );\n assert_eq!(\n parsed\n .get(\"providers\")\n .and_then(|p| p.get(\"sglang\"))\n .and_then(|t| t.get(\"api_key\"))\n .and_then(toml::Value::as_str),\n Some(\"sglang-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn save_api_key_for_deepseek_cn_uses_root_deepseek_storage() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-save-key-cn-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let path = save_api_key_for(ApiProvider::DeepseekCN, \"cn-saved-key\")?;\n let contents = fs::read_to_string(&path)?;\n let parsed: toml::Value = toml::from_str(&contents)?;\n\n assert_eq!(\n parsed.get(\"api_key\").and_then(toml::Value::as_str),\n Some(\"cn-saved-key\")\n );\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_reads_facade_provider_table() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-provider-table-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-flash\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://nim-table.example/v1\"\nmodel = \"deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n assert_eq!(config.deepseek_base_url(), \"https://nim-table.example/v1\");\n assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);\n Ok(())\n }\n\n #[test]\n fn nvidia_nim_provider_table_key_overrides_root_deepseek_key() -> Result<()> {\n let _lock = lock_test_env();\n let nanos = SystemTime::now()\n .duration_since(UNIX_EPOCH)\n .unwrap()\n .as_nanos();\n let temp_root = env::temp_dir().join(format!(\n \"deepseek-tui-nim-root-key-precedence-test-{}-{}\",\n std::process::id(),\n nanos\n ));\n fs::create_dir_all(&temp_root)?;\n let _guard = EnvGuard::new(&temp_root);\n\n let config_path = temp_root.join(\".deepseek\").join(\"config.toml\");\n ensure_parent_dir(&config_path)?;\n fs::write(\n &config_path,\n r#\"api_key = \"deepseek-root-key\"\nprovider = \"nvidia-nim\"\n\n[providers.nvidia_nim]\napi_key = \"nim-table-key\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\nmodel = \"deepseek-ai/deepseek-v4-pro\"\n\"#,\n )?;\n\n let config = Config::load(None, None)?;\n assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);\n assert_eq!(config.deepseek_api_key()?, \"nim-table-key\");\n Ok(())\n }\n\n // ========================================================================\n // Provider Capability Matrix tests\n // ========================================================================\n\n #[test]\n fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-chat\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-chat\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n assert_eq!(deprecation.retirement_date, \"2026-07-24\");\n assert_eq!(deprecation.retirement_utc, \"2026-07-24T15:59:00Z\");\n }\n\n #[test]\n fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-reasoner\");\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n\n let deprecation = cap\n .alias_deprecation\n .as_ref()\n .expect(\"alias deprecation metadata\");\n assert_eq!(deprecation.alias, \"deepseek-reasoner\");\n assert_eq!(deprecation.replacement, \"deepseek-v4-flash\");\n }\n\n #[test]\n fn provider_capability_deepseek_v4_flash_has_no_alias_deprecation() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-flash\");\n assert!(cap.alias_deprecation.is_none());\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {\n let cap = provider_capability(ApiProvider::NvidiaNim, DEFAULT_NVIDIA_NIM_FLASH_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Openrouter, DEFAULT_OPENROUTER_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n // OpenRouter does not return DeepSeek prompt-cache telemetry.\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_novita_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Fireworks, DEFAULT_FIREWORKS_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {\n let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL);\n assert_eq!(\n cap.context_window,\n crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 384_000);\n assert!(cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n }\n\n #[test]\n fn provider_capability_openai_custom_model_is_chat_completions_without_thinking() {\n let cap = provider_capability(ApiProvider::Openai, \"glm-5\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_ollama_is_openai_compatible_without_thinking() {\n let cap = provider_capability(ApiProvider::Ollama, \"deepseek-v3.1:671b\");\n assert_eq!(cap.context_window, 8192);\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n assert!(!cap.cache_telemetry_supported);\n assert_eq!(\n cap.request_payload_mode,\n RequestPayloadMode::ChatCompletions\n );\n }\n\n #[test]\n fn provider_capability_non_v4_model_has_smaller_window() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-coder\");\n assert_eq!(\n cap.context_window,\n crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS\n );\n assert_eq!(cap.max_output, 4096);\n assert!(!cap.thinking_supported);\n }\n\n #[test]\n fn provider_capability_roundtrip_serialization() {\n let cap = provider_capability(ApiProvider::Deepseek, \"deepseek-v4-pro\");\n let json = serde_json::to_value(&cap).unwrap();\n let deserialized: ProviderCapability = serde_json::from_value(json).unwrap();\n assert_eq!(cap, deserialized);\n }\n}\n"}}, "crates/cli/src/lib.rs": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785745838.7130206, "n": 100977, "h": "fcda914fd68acf44", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n /// Workspace directory for TUI file tools\n #[arg(short = 'C', long = \"workspace\", alias = \"cd\", value_name = \"DIR\")]\n workspace: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n /// Continue the most recent interactive session for this workspace.\n #[arg(short = 'c', long = \"continue\")]\n continue_session: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run CodeWhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run a non-interactive prompt through the TUI runtime.\n #[command(after_help = \"\\\nExamples:\n codewhale exec \\\"explain this function\\\"\n codewhale exec --auto \\\"list crates/ with ls\\\"\n codewhale exec --auto --output-format stream-json \\\"fix the failing test\\\"\n\nCommon forwarded flags:\n --auto Enable tool-backed agent mode with auto-approvals\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\nPlain `codewhale exec` is a one-shot model response. Use `--auto` for\nnon-interactive filesystem/shell tool use, matching the supported automation\npath used by stream-json wrappers.\n\")]\n Exec(TuiPassthroughArgs),\n /// Generate SWE-bench prediction rows from CodeWhale runs.\n #[command(after_help = \"\\\nExamples:\n codewhale swebench run --instance-id django__django-12345 --issue-file issue.md\n codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl\n\nThis command forwards to the TUI runtime. `run` invokes tool-backed agent mode\nand writes a SWE-bench-compatible JSONL prediction row from the resulting\nworking-tree diff. `export` only writes the current diff.\n\")]\n Swebench(TuiPassthroughArgs),\n /// Run a CodeWhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Swebench(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"swebench\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let forwarded = root_tui_passthrough(&cli)?;\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn root_tui_passthrough(cli: &Cli) -> Result> {\n let mut forwarded = Vec::new();\n if cli.continue_session {\n forwarded.push(\"--continue\".to_string());\n }\n\n let prompt =\n cli.prompt_flag\n .iter()\n .chain(cli.prompt.iter())\n .fold(String::new(), |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n });\n if !prompt.is_empty() {\n if cli.continue_session {\n bail!(\n \"`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue ` to continue a session non-interactively.\"\n );\n }\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n\n Ok(forwarded)\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}\"\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if let Some(workspace) = cli.workspace.as_ref() {\n cmd.arg(\"--workspace\").arg(workspace);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--workspace\",\n \"/tmp/workspace\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(cli.workspace, Some(PathBuf::from(\"/tmp/workspace\")));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--workspace\",\n \"/tmp/codewhale-workspace\",\n ]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n let args: Vec = cmd\n .get_args()\n .map(|arg| arg.to_string_lossy().into_owned())\n .collect();\n assert!(\n args.windows(2)\n .any(|pair| pair == [\"--workspace\", \"/tmp/codewhale-workspace\"]),\n \"expected workspace forwarding in args: {args:?}\"\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_top_level_continue_for_interactive_resume() {\n let cli = parse_ok(&[\"codewhale\", \"--continue\"]);\n\n assert!(cli.continue_session);\n assert!(cli.prompt_flag.is_none());\n assert!(cli.prompt.is_empty());\n assert_eq!(root_tui_passthrough(&cli).unwrap(), vec![\"--continue\"]);\n }\n\n #[test]\n fn top_level_continue_rejects_one_shot_prompt() {\n let cli = parse_ok(&[\"codewhale\", \"--continue\", \"-p\", \"follow up\"]);\n\n let err = root_tui_passthrough(&cli).expect_err(\"prompted continue should be rejected\");\n assert!(\n err.to_string()\n .contains(\"codewhale exec --continue \")\n );\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--continue\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "698722c9464d1fd7b9dc6181667fcab29f1871c2": {"t": 1785756830.9437025, "n": 100977, "h": "fcda914fd68acf44", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n /// Workspace directory for TUI file tools\n #[arg(short = 'C', long = \"workspace\", alias = \"cd\", value_name = \"DIR\")]\n workspace: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n /// Continue the most recent interactive session for this workspace.\n #[arg(short = 'c', long = \"continue\")]\n continue_session: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run CodeWhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run a non-interactive prompt through the TUI runtime.\n #[command(after_help = \"\\\nExamples:\n codewhale exec \\\"explain this function\\\"\n codewhale exec --auto \\\"list crates/ with ls\\\"\n codewhale exec --auto --output-format stream-json \\\"fix the failing test\\\"\n\nCommon forwarded flags:\n --auto Enable tool-backed agent mode with auto-approvals\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\nPlain `codewhale exec` is a one-shot model response. Use `--auto` for\nnon-interactive filesystem/shell tool use, matching the supported automation\npath used by stream-json wrappers.\n\")]\n Exec(TuiPassthroughArgs),\n /// Generate SWE-bench prediction rows from CodeWhale runs.\n #[command(after_help = \"\\\nExamples:\n codewhale swebench run --instance-id django__django-12345 --issue-file issue.md\n codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl\n\nThis command forwards to the TUI runtime. `run` invokes tool-backed agent mode\nand writes a SWE-bench-compatible JSONL prediction row from the resulting\nworking-tree diff. `export` only writes the current diff.\n\")]\n Swebench(TuiPassthroughArgs),\n /// Run a CodeWhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Swebench(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"swebench\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let forwarded = root_tui_passthrough(&cli)?;\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn root_tui_passthrough(cli: &Cli) -> Result> {\n let mut forwarded = Vec::new();\n if cli.continue_session {\n forwarded.push(\"--continue\".to_string());\n }\n\n let prompt =\n cli.prompt_flag\n .iter()\n .chain(cli.prompt.iter())\n .fold(String::new(), |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n });\n if !prompt.is_empty() {\n if cli.continue_session {\n bail!(\n \"`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue ` to continue a session non-interactively.\"\n );\n }\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n\n Ok(forwarded)\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}\"\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if let Some(workspace) = cli.workspace.as_ref() {\n cmd.arg(\"--workspace\").arg(workspace);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--workspace\",\n \"/tmp/workspace\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(cli.workspace, Some(PathBuf::from(\"/tmp/workspace\")));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--workspace\",\n \"/tmp/codewhale-workspace\",\n ]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n let args: Vec = cmd\n .get_args()\n .map(|arg| arg.to_string_lossy().into_owned())\n .collect();\n assert!(\n args.windows(2)\n .any(|pair| pair == [\"--workspace\", \"/tmp/codewhale-workspace\"]),\n \"expected workspace forwarding in args: {args:?}\"\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_top_level_continue_for_interactive_resume() {\n let cli = parse_ok(&[\"codewhale\", \"--continue\"]);\n\n assert!(cli.continue_session);\n assert!(cli.prompt_flag.is_none());\n assert!(cli.prompt.is_empty());\n assert_eq!(root_tui_passthrough(&cli).unwrap(), vec![\"--continue\"]);\n }\n\n #[test]\n fn top_level_continue_rejects_one_shot_prompt() {\n let cli = parse_ok(&[\"codewhale\", \"--continue\", \"-p\", \"follow up\"]);\n\n let err = root_tui_passthrough(&cli).expect_err(\"prompted continue should be rejected\");\n assert!(\n err.to_string()\n .contains(\"codewhale exec --continue \")\n );\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--continue\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "25ce4f59700bcac15ae0df913c9540db9b95a051": {"t": 1785756832.650948, "n": 99717, "h": "882e737b4e912661", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n /// Workspace directory for TUI file tools\n #[arg(short = 'C', long = \"workspace\", alias = \"cd\", value_name = \"DIR\")]\n workspace: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run CodeWhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run a non-interactive prompt through the TUI runtime.\n #[command(after_help = \"\\\nExamples:\n codewhale exec \\\"explain this function\\\"\n codewhale exec --auto \\\"list crates/ with ls\\\"\n codewhale exec --auto --output-format stream-json \\\"fix the failing test\\\"\n\nCommon forwarded flags:\n --auto Enable tool-backed agent mode with auto-approvals\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\nPlain `codewhale exec` is a one-shot model response. Use `--auto` for\nnon-interactive filesystem/shell tool use, matching the supported automation\npath used by stream-json wrappers.\n\")]\n Exec(TuiPassthroughArgs),\n /// Generate SWE-bench prediction rows from CodeWhale runs.\n #[command(after_help = \"\\\nExamples:\n codewhale swebench run --instance-id django__django-12345 --issue-file issue.md\n codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl\n\nThis command forwards to the TUI runtime. `run` invokes tool-backed agent mode\nand writes a SWE-bench-compatible JSONL prediction row from the resulting\nworking-tree diff. `export` only writes the current diff.\n\")]\n Swebench(TuiPassthroughArgs),\n /// Run a CodeWhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Swebench(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"swebench\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}\"\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if let Some(workspace) = cli.workspace.as_ref() {\n cmd.arg(\"--workspace\").arg(workspace);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--workspace\",\n \"/tmp/workspace\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(cli.workspace, Some(PathBuf::from(\"/tmp/workspace\")));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--workspace\",\n \"/tmp/codewhale-workspace\",\n ]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n let args: Vec = cmd\n .get_args()\n .map(|arg| arg.to_string_lossy().into_owned())\n .collect();\n assert!(\n args.windows(2)\n .any(|pair| pair == [\"--workspace\", \"/tmp/codewhale-workspace\"]),\n \"expected workspace forwarding in args: {args:?}\"\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "2947eff9d14987dd2ff2defa570ed7c5d7efedd1": {"t": 1785756834.1897697, "n": 97718, "h": "2f4e4e35fc92af57", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run CodeWhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the CodeWhale non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a CodeWhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}\"\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "5fa24733e9a1d11315506309b8edb0fdf2b94f04": {"t": 1785756835.7936575, "n": 97738, "h": "da1954efbbe26d67", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run CodeWhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the CodeWhale non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a CodeWhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/CodeWhale/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "ddaabbfed296a54a1efb3b16f6ac250e71b9d9b1": {"t": 1785756837.5000386, "n": 97741, "h": "78d5fc797a00cbec", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run Codewhale diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the Codewhale non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a Codewhale-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "3efa6aad7d28ae2ac7bb1fdd65c14f2376659247": {"t": 1785756839.092332, "n": 97746, "h": "ee2fe4fa1c347130", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"codewhale\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"codewhale\",\n override_usage = \"codewhale [OPTIONS] [PROMPT]\\n codewhale [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(codewhale completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n codewhale completion zsh > ~/.zfunc/_codewhale\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n codewhale completion fish > ~/.config/fish/completions/codewhale.fish\n\n PowerShell (current shell only):\n codewhale completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `codewhale` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"codewhale\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `codewhale resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"codewhale-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\\n\\\n - On Windows, run `where codewhale` and `where codewhale-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `codewhale` and \\\n`codewhale-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `codewhale-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"codewhale-tui\" on Windows.\n let expected = current.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `codewhale-tui` binary not found at {}.\\n\\\n\\n\\\nThe `codewhale` dispatcher delegates interactive sessions to a sibling \\\n`codewhale-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g codewhale (downloads both binaries)\\n\\\n • cargo: cargo install codewhale-cli codewhale-tui --locked\\n\\\n • GitHub Releases: download BOTH `codewhale-` AND \\\n`codewhale-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `codewhale-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(codewhale completion bash)\",\n \"~/.local/share/bash-completion/completions/codewhale\",\n \"fpath=(~/.zfunc $fpath)\",\n \"codewhale completion fish > ~/.config/fish/completions/codewhale.fish\",\n \"codewhale completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `codewhale-tui.exe`, not bail out looking for an\n /// extension-less `codewhale-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"codewhale\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"codewhale-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/codewhale-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/codewhale-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where codewhale\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"codewhale.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"codewhale-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless codewhale-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "c6d73d98ded071c62c56448f9451dbc61ad4acef": {"t": 1785756840.864212, "n": 97696, "h": "a040c194e12b8282", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse codewhale_agent::ModelRegistry;\nuse codewhale_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse codewhale_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse codewhale_mcp::{McpServerDefinition, run_stdio_server};\nuse codewhale_secrets::Secrets;\nuse codewhale_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n codewhale_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use codewhale_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "8597afc0763e3d345a295b4d62b6effda634d246": {"t": 1785756842.6702387, "n": 97681, "h": "a8cca60e21a02f11", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n WanjieArk,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::WanjieArk => ProviderKind::WanjieArk,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::WanjieArk => \"wanjie-ark\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 11] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::WanjieArk,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n ProviderKind::WanjieArk => &[\n \"WANJIE_ARK_API_KEY\",\n \"WANJIE_API_KEY\",\n \"WANJIE_MAAS_API_KEY\",\n ],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::WanjieArk\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::WanjieArk {\n cmd.env(\"WANJIE_ARK_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"wanjie-ark\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::WanjieArk,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "485ba7bbd4937a7bab0ed41a0a5dd26c7c6b55b5": {"t": 1785756844.2322187, "n": 96669, "h": "40a411b8105311e7", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n #[command(after_help = \"\\\nCommon forwarded flags:\n --auto Enable agentic mode with tool access\n --json Emit summary JSON\n --resume Resume a previous session by ID or prefix\n --session-id Resume a previous session by ID or prefix\n --continue Continue the most recent session for this workspace\n --output-format Output format: text or stream-json\n\")]\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 10] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"exec\",\n vec![\n \"--auto\",\n \"--json\",\n \"--resume\",\n \"--session-id\",\n \"--continue\",\n \"--output-format\",\n \"stream-json\",\n ],\n ),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "8f33e4bd48dfd1d23e7bc90ccff9834773a320eb": {"t": 1785756846.30735, "n": 95845, "h": "213fb36530573814", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Atlascloud,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Atlascloud => ProviderKind::Atlascloud,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Atlascloud => \"atlascloud\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 10] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openai,\n ProviderKind::Atlascloud,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n ProviderKind::Atlascloud => &[\"ATLASCLOUD_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Atlascloud\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n if resolved_runtime.provider == ProviderKind::Atlascloud {\n cmd.env(\"ATLASCLOUD_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "524c513c03b77bb71bdaa230a4134bf4325e04e3": {"t": 1785756847.9815612, "n": 95313, "h": "e28aaf59707bcb8c", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n yolo: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "665801bb8ee673dfba5002c603fda258387d00ce": {"t": 1785756849.545663, "n": 95289, "h": "71bc6a0f562702e5", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n /// YOLO mode: auto-approve all tools\n #[arg(long)]\n yolo: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n yolo: Some(cli.yolo),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if cli.yolo {\n cmd.env(\"DEEPSEEK_YOLO\", \"true\");\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "07410521d71bc35002aaae443707b7e787fa773f": {"t": 1785756851.3314614, "n": 95118, "h": "891041c15c7fe97b", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and secret-store storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from secret store and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from secret store but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"secret store\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> secret store -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"secret store: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"secret-store\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and secret store\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config store env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"store\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the configured secret store.\n/// Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\n \"skipped {slot}: failed to write to secret store: {err}\"\n ));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"secret store backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> secret store -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"secret store: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "838078430840170fbddfe8d0a5d3427da5d0eecd": {"t": 1785756853.0171802, "n": 95048, "h": "9f70a62ac83f7ce3", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_display_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "218d797b0b2891e75c702e536ff9b963167a04d3": {"t": 1785756854.5311565, "n": 95040, "h": "4db8e382bfe0d6af", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(short = 'p', long = \"prompt\", value_name = \"PROMPT\")]\n prompt_flag: Option,\n #[arg(\n value_name = \"PROMPT\",\n trailing_var_arg = true,\n allow_hyphen_values = true\n )]\n prompt: Vec,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n let prompt = cli.prompt_flag.iter().chain(cli.prompt.iter()).fold(\n String::new(),\n |mut acc, part| {\n if !acc.is_empty() {\n acc.push(' ');\n }\n acc.push_str(part);\n acc\n },\n );\n if !prompt.is_empty() {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert!(cli.prompt.is_empty());\n }\n\n #[test]\n fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt, vec![\"hello\", \"world\"]);\n assert!(cli.command.is_none());\n }\n\n #[test]\n fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"hello\", \"world\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"hello\"));\n assert_eq!(cli.prompt, vec![\"world\"]);\n }\n\n #[test]\n fn known_subcommands_still_parse_before_prompt_tail() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n\n assert!(cli.prompt.is_empty());\n assert!(matches!(cli.command, Some(Commands::Doctor(_))));\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "724af9494a13fadbec393e69f59948dded04e312": {"t": 1785756856.3685777, "n": 93983, "h": "a7aa27130e52d78a", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\", hide = true)]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n // Accepted for older scripts, but no longer forwarded: the interactive TUI\n // always owns the alternate screen to avoid host scrollback hijacking.\n let _ = cli.no_alt_screen;\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "f97604c3f0b75c3bce18420829b2d422d02dacc5": {"t": 1785756858.1654668, "n": 93883, "h": "4daa20940265e938", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openai\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n if resolved_runtime.provider == ProviderKind::Openai {\n cmd.env(\"OPENAI_API_KEY\", api_key);\n }\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn command_env(cmd: &Command, name: &str) -> Option {\n let name = std::ffi::OsStr::new(name);\n cmd.get_envs().find_map(|(key, value)| {\n if key == name {\n value.map(|v| v.to_string_lossy().into_owned())\n } else {\n None\n }\n })\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn build_tui_command_allows_openai_and_forwards_provider_key() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let cli = parse_ok(&[\"deepseek\", \"--provider\", \"openai\"]);\n let resolved = ResolvedRuntimeOptions {\n provider: ProviderKind::Openai,\n model: \"glm-5\".to_string(),\n api_key: Some(\"resolved-openai-key\".to_string()),\n api_key_source: Some(RuntimeApiKeySource::Keyring),\n base_url: \"https://openai-compatible.example/v4\".to_string(),\n auth_mode: Some(\"api_key\".to_string()),\n output_mode: None,\n log_level: None,\n telemetry: false,\n approval_policy: None,\n sandbox_mode: None,\n http_headers: std::collections::BTreeMap::new(),\n };\n\n let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect(\"command\");\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_PROVIDER\").as_deref(),\n Some(\"openai\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_MODEL\").as_deref(),\n Some(\"glm-5\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_BASE_URL\").as_deref(),\n Some(\"https://openai-compatible.example/v4\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"OPENAI_API_KEY\").as_deref(),\n Some(\"resolved-openai-key\")\n );\n assert_eq!(\n command_env(&cmd, \"DEEPSEEK_API_KEY_SOURCE\").as_deref(),\n Some(\"keyring\")\n );\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let _lock = env_lock();\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n let custom_str = custom.to_string_lossy().into_owned();\n let _bin = ScopedEnvVar::set(\"DEEPSEEK_TUI_BIN\", &custom_str);\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "f8d5fd84d7f1c848661a9b184b45dd93b5d80d9e": {"t": 1785756861.9510245, "n": 91708, "h": "965ac9d689266603", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version = env!(\"DEEPSEEK_BUILD_VERSION\"),\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "4369410df70ffff1e638c461ffa4395ac665f1d2": {"t": 1785756864.6492229, "n": 91675, "h": "25278e64cfbdd1f2", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n provider_env_value(provider).is_some()\n}\n\nfn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {\n match provider {\n ProviderKind::Deepseek => &[\"DEEPSEEK_API_KEY\"],\n ProviderKind::Openrouter => &[\"OPENROUTER_API_KEY\"],\n ProviderKind::Novita => &[\"NOVITA_API_KEY\"],\n ProviderKind::NvidiaNim => &[\"NVIDIA_API_KEY\", \"NVIDIA_NIM_API_KEY\", \"DEEPSEEK_API_KEY\"],\n ProviderKind::Fireworks => &[\"FIREWORKS_API_KEY\"],\n ProviderKind::Sglang => &[\"SGLANG_API_KEY\"],\n ProviderKind::Vllm => &[\"VLLM_API_KEY\"],\n ProviderKind::Ollama => &[\"OLLAMA_API_KEY\"],\n ProviderKind::Openai => &[\"OPENAI_API_KEY\"],\n }\n}\n\nfn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {\n provider_env_vars(provider).iter().find_map(|var| {\n std::env::var(var)\n .ok()\n .filter(|value| !value.trim().is_empty())\n .map(|value| (*var, value))\n })\n}\n\nfn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_deref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_deref())\n .flatten();\n slot.or(root).filter(|v| !v.trim().is_empty())\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n provider_config_api_key(store, provider).is_some()\n}\n\nfn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .filter(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n provider_keyring_api_key(secrets, provider).is_some()\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec {\n let provider = store.config.provider;\n let config_key = provider_config_api_key(store, provider);\n let keyring_key = provider_keyring_api_key(secrets, provider);\n let env_key = provider_env_value(provider);\n\n let active_source = if config_key.is_some() {\n \"config\"\n } else if keyring_key.is_some() {\n \"keyring\"\n } else if env_key.is_some() {\n \"env\"\n } else {\n \"missing\"\n };\n let active_last4 = config_key\n .map(last4_label)\n .or_else(|| keyring_key.as_deref().map(last4_label))\n .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));\n let active_label = active_last4\n .map(|last4| format!(\"{active_source} (last4: {last4})\"))\n .unwrap_or_else(|| active_source.to_string());\n\n let env_var_label = env_key\n .as_ref()\n .map(|(name, _)| (*name).to_string())\n .unwrap_or_else(|| provider_env_vars(provider).join(\"/\"));\n let env_status = env_key\n .as_ref()\n .map(|(_, value)| format!(\"set, last4: {}\", last4_label(value)))\n .unwrap_or_else(|| \"unset\".to_string());\n\n vec![\n format!(\"provider: {}\", provider.as_str()),\n format!(\"active source: {active_label}\"),\n \"lookup order: config -> keyring -> env\".to_string(),\n format!(\n \"config file: {} ({})\",\n store.path().display(),\n source_status(config_key, \"missing\")\n ),\n format!(\n \"keyring: {} ({})\",\n secrets.backend_name(),\n source_status(keyring_key.as_deref(), \"missing\")\n ),\n format!(\"env var: {env_var_label} ({env_status})\"),\n ]\n}\n\nfn source_status(value: Option<&str>, missing_label: &str) -> String {\n value\n .map(|v| format!(\"set, last4: {}\", last4_label(v)))\n .unwrap_or_else(|| missing_label.to_string())\n}\n\nfn last4_label(value: &str) -> String {\n let trimmed = value.trim();\n let chars: Vec = trimmed.chars().collect();\n if chars.len() <= 4 {\n return \"\".to_string();\n }\n let last4: String = chars[chars.len() - 4..].iter().collect();\n format!(\"...{last4}\")\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n for line in auth_status_lines(store, secrets) {\n println!(\"{line}\");\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n use std::ffi::OsString;\n use std::sync::{Mutex, OnceLock};\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n fn env_lock() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|p| p.into_inner())\n }\n\n struct ScopedEnvVar {\n name: &'static str,\n previous: Option,\n }\n\n impl ScopedEnvVar {\n fn set(name: &'static str, value: &str) -> Self {\n let previous = std::env::var_os(name);\n // Safety: tests using this helper serialize with env_lock() and\n // restore the original value in Drop.\n unsafe { std::env::set_var(name, value) };\n Self { name, previous }\n }\n }\n\n impl Drop for ScopedEnvVar {\n fn drop(&mut self) {\n // Safety: tests using this helper serialize with env_lock().\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(self.name, previous);\n } else {\n std::env::remove_var(self.name);\n }\n }\n }\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_reports_all_active_provider_sources_with_last4() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let _lock = env_lock();\n let _env = ScopedEnvVar::set(\"DEEPSEEK_API_KEY\", \"sk-env-1111\");\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-status-table-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n store.config.api_key = Some(\"sk-config-3333\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-config-3333\".to_string());\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring-2222\").unwrap();\n let secrets = Secrets::new(inner);\n\n let output = auth_status_lines(&store, &secrets).join(\"\\n\");\n\n assert!(output.contains(\"provider: deepseek\"));\n assert!(output.contains(\"active source: config (last4: ...3333)\"));\n assert!(output.contains(\"lookup order: config -> keyring -> env\"));\n assert!(output.contains(\"config file: \"));\n assert!(output.contains(\"set, last4: ...3333\"));\n assert!(output.contains(\"keyring: in-memory (test) (set, last4: ...2222)\"));\n assert!(output.contains(\"env var: DEEPSEEK_API_KEY (set, last4: ...1111)\"));\n assert!(!output.contains(\"sk-config-3333\"));\n assert!(!output.contains(\"sk-keyring-2222\"));\n assert!(!output.contains(\"sk-env-1111\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "78c415f40c04c91aa301a89121cba1a73fc96eaa": {"t": 1785756866.2106576, "n": 86105, "h": "879473169aae1213", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n Ollama,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n ProviderArg::Ollama => ProviderKind::Ollama,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n ProviderKind::Ollama => \"ollama\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 9] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Ollama,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty())\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n let provider = store.config.provider;\n println!(\"provider: {}\", provider.as_str());\n println!(\"credential precedence: config -> keyring -> env\");\n let slot = provider_slot(provider);\n let file_set = provider_config_set(store, provider);\n let keyring_set = (!file_set).then(|| provider_keyring_set(secrets, provider));\n let env_set = provider_env_set(provider);\n let active = if file_set {\n \"config\"\n } else if keyring_set == Some(true) {\n \"keyring\"\n } else if env_set {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot} auth: config={}, keyring={}, env={}, active={active}\",\n file_set,\n keyring_status_short(keyring_set),\n env_set\n );\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {\n store.config.provider = provider;\n let provider_cfg = store.config.providers.for_provider_mut(provider);\n if provider_cfg.base_url.is_none() {\n provider_cfg.base_url = Some(\"http://localhost:11434/v1\".to_string());\n }\n store.save()?;\n println!(\n \"configured {slot} provider in {} (API key optional)\",\n store.path().display()\n );\n return Ok(());\n }\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n | ProviderKind::Ollama\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"ollama\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_set_ollama_accepts_empty_key_and_records_base_url() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-ollama-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Ollama,\n api_key: None,\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"ollama auth set should not require a key\");\n\n assert_eq!(store.config.provider, ProviderKind::Ollama);\n assert_eq!(\n store.config.providers.ollama.base_url.as_deref(),\n Some(\"http://localhost:11434/v1\")\n );\n assert_eq!(store.config.providers.ollama.api_key, None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "633092167ced026cd0865e6c484286966ef150f6": {"t": 1785756868.175256, "n": 83809, "h": "bc4bedd3bb25ff48", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty())\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n let provider = store.config.provider;\n println!(\"provider: {}\", provider.as_str());\n println!(\"credential precedence: config -> keyring -> env\");\n let slot = provider_slot(provider);\n let file_set = provider_config_set(store, provider);\n let keyring_set = (!file_set).then(|| provider_keyring_set(secrets, provider));\n let env_set = provider_env_set(provider);\n let active = if file_set {\n \"config\"\n } else if keyring_set == Some(true) {\n \"keyring\"\n } else if env_set {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot} auth: config={}, keyring={}, env={}, active={active}\",\n file_set,\n keyring_status_short(keyring_set),\n env_set\n );\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if !resolved_runtime.http_headers.is_empty() {\n let encoded = resolved_runtime\n .http_headers\n .iter()\n .map(|(name, value)| format!(\"{}={}\", name.trim(), value.trim()))\n .collect::>()\n .join(\",\");\n cmd.env(\"DEEPSEEK_HTTP_HEADERS\", encoded);\n }\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "ccb4662c2a8625a8cf4d73e1cb59e7840c155e57": {"t": 1785756872.10982, "n": 83481, "h": "c6e4c1cfefdb0eb1", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{\n CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,\n};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider and credential source state.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from config and keyring storage.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, args.args)\n }\n Some(Commands::Doctor(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n run_resume_command(&cli, &resolved_runtime, args)\n }\n Some(Commands::Fork(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn resolve_runtime_for_dispatch(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n) -> ResolvedRuntimeOptions {\n let runtime_secrets = Secrets::auto_detect();\n resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)\n}\n\nfn resolve_runtime_for_dispatch_with_secrets(\n store: &mut ConfigStore,\n runtime_overrides: &CliRuntimeOverrides,\n secrets: &Secrets,\n) -> ResolvedRuntimeOptions {\n let mut resolved = store\n .config\n .resolve_runtime_options_with_secrets(runtime_overrides, secrets);\n\n if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)\n && !provider_config_set(store, resolved.provider)\n && let Some(api_key) = resolved.api_key.clone()\n {\n write_provider_api_key_to_config(store, resolved.provider, &api_key);\n match store.save() {\n Ok(()) => {\n eprintln!(\n \"info: recovered API key from OS keyring and saved it to {}\",\n store.path().display()\n );\n resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);\n }\n Err(err) => {\n eprintln!(\n \"warning: recovered API key from OS keyring but failed to save {}: {err}\",\n store.path().display()\n );\n }\n }\n }\n\n resolved\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &Secrets::auto_detect())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n let destination = if keyring_saved {\n format!(\"{} and {}\", store.path().display(), secrets.backend_name())\n } else {\n store.path().display().to_string()\n };\n if provider == ProviderKind::Deepseek {\n println!(\"logged in using API key mode (deepseek); saved key to {destination}\");\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {destination}\",\n provider.as_str(),\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &Secrets::auto_detect())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {\n let active_provider = store.config.provider;\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n clear_provider_api_key_from_keyring(secrets, active_provider);\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\n#[cfg(test)]\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {\n secrets\n .get(provider_slot(provider))\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty())\n}\n\nfn write_provider_api_key_to_keyring(\n secrets: &Secrets,\n provider: ProviderKind,\n api_key: &str,\n) -> bool {\n secrets.set(provider_slot(provider), api_key).is_ok()\n}\n\nfn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {\n let _ = secrets.delete(provider_slot(provider));\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n let provider = store.config.provider;\n println!(\"provider: {}\", provider.as_str());\n println!(\"credential precedence: config -> keyring -> env\");\n let slot = provider_slot(provider);\n let file_set = provider_config_set(store, provider);\n let keyring_set = (!file_set).then(|| provider_keyring_set(secrets, provider));\n let env_set = provider_env_set(provider);\n let active = if file_set {\n \"config\"\n } else if keyring_set == Some(true) {\n \"keyring\"\n } else if env_set {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot} auth: config={}, keyring={}, env={}, active={active}\",\n file_set,\n keyring_status_short(keyring_set),\n env_set\n );\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n if keyring_saved {\n println!(\n \"saved API key for {slot} to {} and {}\",\n store.path().display(),\n secrets.backend_name()\n );\n } else {\n println!(\"saved API key for {slot} to {}\", store.path().display());\n }\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_file = provider_config_set(store, provider);\n let in_keyring = !in_file && provider_keyring_set(secrets, provider);\n let in_env = provider_env_set(provider);\n // Report the highest-priority source that has it.\n let source = if in_file {\n Some(\"config-file\")\n } else if in_keyring {\n Some(\"keyring\")\n } else if in_env {\n Some(\"env\")\n } else {\n None\n };\n match source {\n Some(source) => println!(\"{slot}: set (source: {source})\"),\n None => println!(\"{slot}: not set\"),\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n clear_provider_api_key_from_keyring(secrets, provider);\n store.save()?;\n println!(\"cleared API key for {slot} from config and keyring\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider config keyring env active\");\n let active_provider = store.config.provider;\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let file = provider_config_set(store, provider);\n let keyring = (provider == active_provider && !file)\n .then(|| provider_keyring_set(secrets, provider));\n let env = provider_env_set(provider);\n let active = if file {\n \"config\"\n } else if keyring == Some(true) {\n \"keyring\"\n } else if env {\n \"env\"\n } else {\n \"missing\"\n };\n println!(\n \"{slot:<12} {} {} {} {active}\",\n yes_no(file),\n keyring_status_short(keyring),\n yes_no(env)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn keyring_status_short(state: Option) -> &'static str {\n match state {\n Some(true) => \"yes\",\n Some(false) => \"no \",\n None => \"n/a\",\n }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n let source = resolved_runtime\n .api_key_source\n .unwrap_or(RuntimeApiKeySource::Env)\n .as_env_value();\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", source);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n assert_eq!(\n inner.get(\"deepseek\").unwrap().as_deref(),\n Some(\"sk-keyring\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-stale\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_status_and_list_only_probe_active_provider_keyring() {\n use deepseek_secrets::{KeyringStore, SecretsError};\n use std::sync::{Arc, Mutex};\n\n #[derive(Default)]\n struct RecordingStore {\n gets: Mutex>,\n }\n\n impl KeyringStore for RecordingStore {\n fn get(&self, key: &str) -> Result, SecretsError> {\n self.gets.lock().unwrap().push(key.to_string());\n Ok(None)\n }\n\n fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn delete(&self, _key: &str) -> Result<(), SecretsError> {\n Ok(())\n }\n\n fn backend_name(&self) -> &'static str {\n \"recording\"\n }\n }\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.provider = ProviderKind::Deepseek;\n let inner = Arc::new(RecordingStore::default());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)\n .expect(\"status should succeed\");\n run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)\n .expect(\"list should succeed\");\n\n assert_eq!(\n inner.gets.lock().unwrap().as_slice(),\n [\"deepseek\", \"deepseek\"]\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn dispatch_keyring_recovery_self_heals_into_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"ring-key\").unwrap();\n let secrets = Secrets::new(inner);\n\n let resolved = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &secrets,\n );\n\n assert_eq!(resolved.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n assert_eq!(store.config.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"ring-key\")\n );\n\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"ring-key\\\"\"));\n\n let resolved_again = resolve_runtime_for_dispatch_with_secrets(\n &mut store,\n &CliRuntimeOverrides::default(),\n &no_keyring_secrets(),\n );\n assert_eq!(resolved_again.api_key.as_deref(), Some(\"ring-key\"));\n assert_eq!(\n resolved_again.api_key_source,\n Some(RuntimeApiKeySource::ConfigFile)\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "d70bec6ac5be24250be027f5f9214f05f109b3de": {"t": 1785756873.9666593, "n": 73517, "h": "8aa6c3aca52a23f0", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n // Use the full anyhow chain so callers see the underlying\n // cause (e.g. the actual TOML parse error with line/column)\n // instead of just the top-level context message. The bare\n // `{err}` Display impl drops the chain — see #767, where\n // users hit \"failed to parse config at \" with no\n // hint that the real error was a stray BOM or unbalanced\n // quote a few lines down.\n eprintln!(\"error: {err}\");\n for cause in err.chain().skip(1) {\n eprintln!(\" caused by: {cause}\");\n }\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => run_resume_command(&cli, &resolved_runtime, args),\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n // Regression for #767: `run_cli` prints the full anyhow chain so users\n // see the underlying TOML parser error (line/column, expected token)\n // instead of just the top-level \"failed to parse config at \"\n // wrapper. anyhow's bare `Display` impl drops the chain — pin both\n // pieces here so a future refactor of the printing path doesn't\n // silently regress.\n #[test]\n fn anyhow_chain_surfaces_toml_parse_cause() {\n use anyhow::Context;\n let inner = anyhow::anyhow!(\"TOML parse error at line 1, column 20\");\n let err = Err::<(), _>(inner)\n .context(\"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\")\n .unwrap_err();\n\n // What `eprintln!(\"error: {err}\")` prints (top context only).\n assert_eq!(\n err.to_string(),\n \"failed to parse config at C:\\\\Users\\\\test\\\\.deepseek\\\\config.toml\",\n );\n\n // What the `for cause in err.chain().skip(1)` loop iterates over.\n let causes: Vec = err.chain().skip(1).map(ToString::to_string).collect();\n assert_eq!(causes, vec![\"TOML parse error at line 1, column 20\"]);\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "b60568dfc0fbc3e29f05f8b4ad80d99383fe9793": {"t": 1785756877.117298, "n": 71770, "h": "098a7a94140b0712", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read, Write};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => run_resume_command(&cli, &resolved_runtime, args),\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;\n let tui = PathBuf::from(cmd.get_program());\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n exit_with_tui_status(status)\n}\n\nfn run_resume_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n args: TuiPassthroughArgs,\n) -> Result<()> {\n let passthrough = tui_args(\"resume\", args);\n if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {\n return run_dispatcher_resume_picker(cli, resolved_runtime);\n }\n delegate_to_tui(cli, resolved_runtime, passthrough)\n}\n\nfn run_dispatcher_resume_picker(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n) -> Result<()> {\n let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec![\"sessions\".to_string()])?;\n let tui = PathBuf::from(sessions_cmd.get_program());\n let status = sessions_cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n if !status.success() {\n return exit_with_tui_status(status);\n }\n\n println!();\n println!(\"Windows note: enter a session id or prefix from the list above.\");\n println!(\"You can also run `deepseek resume --last` to skip this prompt.\");\n print!(\"Session id/prefix (Enter to cancel): \");\n io::stdout().flush()?;\n\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read session selection\")?;\n let session_id = input.trim();\n if session_id.is_empty() {\n bail!(\"No session selected.\");\n }\n\n delegate_to_tui(\n cli,\n resolved_runtime,\n vec![\"resume\".to_string(), session_id.to_string()],\n )\n}\n\nfn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {\n is_windows && passthrough == [\"resume\"]\n}\n\nfn build_tui_command(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n Ok(cmd)\n}\n\nfn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn dispatcher_resume_picker_only_handles_bare_windows_resume() {\n assert!(should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"--last\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string(), \"abc123\".to_string()],\n true\n ));\n assert!(!should_pick_resume_in_dispatcher(\n &[\"resume\".to_string()],\n false\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "2090792570692082e4f13b5f6a4bbaee6852d018": {"t": 1785756878.6050158, "n": 69197, "h": "7c29f61e92c72a3d", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(&tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(&tui)\n .args(args)\n .status()\n .map_err(|err| anyhow!(\"{}\", tui_spawn_error(&tui, &err)))?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn tui_spawn_error(tui: &Path, err: &io::Error) -> String {\n format!(\n \"failed to spawn companion TUI binary at {}: {err}\\n\\\n\\n\\\nThe `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \\\nto execute it. Common fixes:\\n\\\n - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\\n\\\n - On Windows, run `where deepseek` and `where deepseek-tui`; both should \\\ncome from the same install directory.\\n\\\n - If you downloaded release assets manually, keep both `deepseek` and \\\n`deepseek-tui` binaries together and make sure the TUI binary is executable.\\n\\\n - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \\\nbinary.\",\n tui.display()\n )\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n #[test]\n fn dispatcher_spawn_error_names_path_and_recovery_checks() {\n let err = io::Error::new(io::ErrorKind::PermissionDenied, \"access is denied\");\n let message = tui_spawn_error(Path::new(\"C:/tools/deepseek-tui.exe\"), &err);\n\n assert!(message.contains(\"C:/tools/deepseek-tui.exe\"));\n assert!(message.contains(\"access is denied\"));\n assert!(message.contains(\"where deepseek\"));\n assert!(message.contains(\"DEEPSEEK_TUI_BIN\"));\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "02456429ca8a78d9e3b2b80682d1ea3a0b1a7c44": {"t": 1785756880.1010804, "n": 67867, "h": "b22ca0d569e35b09", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n #[command(after_help = r#\"Examples:\n Bash (current shell only):\n source <(deepseek completion bash)\n\n Bash (persistent, Linux/bash-completion):\n mkdir -p ~/.local/share/bash-completion/completions\n deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek\n # Requires bash-completion to be installed and loaded by your shell.\n\n Zsh:\n mkdir -p ~/.zfunc\n deepseek completion zsh > ~/.zfunc/_deepseek\n # Add to ~/.zshrc if needed:\n # fpath=(~/.zfunc $fpath)\n # autoload -Uz compinit && compinit\n\n Fish:\n mkdir -p ~/.config/fish/completions\n deepseek completion fish > ~/.config/fish/completions/deepseek.fish\n\n PowerShell (current shell only):\n deepseek completion powershell | Out-String | Invoke-Expression\n\nThe command prints the completion script to stdout; redirect it to a path your shell loads automatically.\"#)]\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd.status().context(\"failed to spawn deepseek-tui\")?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(tui).args(args).status()?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\n \"completion\",\n vec![\n \"\",\n \"bash\",\n \"source <(deepseek completion bash)\",\n \"~/.local/share/bash-completion/completions/deepseek\",\n \"fpath=(~/.zfunc $fpath)\",\n \"deepseek completion fish > ~/.config/fish/completions/deepseek.fish\",\n \"deepseek completion powershell | Out-String | Invoke-Expression\",\n ],\n ),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "a335ff5e4c3e1c0734b2ad7097388191fa832f20": {"t": 1785756884.9266667, "n": 66516, "h": "22b4143bea4d6413", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n Vllm,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n ProviderArg::Vllm => ProviderKind::Vllm,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n ProviderKind::Vllm => \"vllm\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 8] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Vllm,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n | ProviderKind::Vllm\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, and vLLM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd.status().context(\"failed to spawn deepseek-tui\")?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(tui).args(args).status()?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"vllm\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Vllm\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\"completion\", vec![\"\", \"bash\"]),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "fc1970fa556306707ec52bc749ea8ecfe9fa31cd": {"t": 1785756887.7118402, "n": 66039, "h": "d84f972443746ee5", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n Fireworks,\n Sglang,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n ProviderArg::Fireworks => ProviderKind::Fireworks,\n ProviderArg::Sglang => ProviderKind::Sglang,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a provider API key to the shared user config file.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the shared user config file. Reads from\n /// `--api-key`, `--api-key-stdin`, or prompts on stdin when\n /// neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the shared user config file.\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Advanced: migrate config-file keys into a platform credential store.\n #[command(hide = true)]\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n run_login_command_with_secrets(store, args, &no_keyring_secrets())\n}\n\nfn run_login_command_with_secrets(\n store: &mut ConfigStore,\n args: LoginArgs,\n _secrets: &Secrets,\n) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek); saved key to {}\",\n store.path().display()\n );\n } else {\n println!(\n \"logged in using API key mode ({}); saved key to {}\",\n provider.as_str(),\n store.path().display()\n );\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n run_logout_command_with_secrets(store, &no_keyring_secrets())\n}\n\nfn run_logout_command_with_secrets(store: &mut ConfigStore, _secrets: &Secrets) -> Result<()> {\n store.config.api_key = None;\n for provider in PROVIDER_LIST {\n clear_provider_api_key_from_config(store, provider);\n }\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical provider credential slot.\nfn provider_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n ProviderKind::Fireworks => \"fireworks\",\n ProviderKind::Sglang => \"sglang\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 7] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Fireworks,\n ProviderKind::Sglang,\n ProviderKind::Openai,\n];\n\nfn no_keyring_secrets() -> Secrets {\n Secrets::new(std::sync::Arc::new(\n deepseek_secrets::InMemoryKeyringStore::new(),\n ))\n}\n\nfn write_provider_api_key_to_config(\n store: &mut ConfigStore,\n provider: ProviderKind,\n api_key: &str,\n) {\n store.config.provider = provider;\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());\n if provider == ProviderKind::Deepseek {\n store.config.api_key = Some(api_key.to_string());\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n}\n\nfn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n}\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(provider_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n match command {\n AuthCommand::Migrate { dry_run } => run_auth_command_with_secrets(\n store,\n AuthCommand::Migrate { dry_run },\n &Secrets::auto_detect(),\n ),\n other => run_auth_command_with_secrets(store, other, &no_keyring_secrets()),\n }\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\"{slot} auth: env={}, config={}\", env_set, file_set);\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n write_provider_api_key_to_config(store, provider, &api_key);\n store.save()?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", store.path().display());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = in_env || in_file;\n if resolved {\n let source = if in_file { \"config-file\" } else { \"env\" };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = provider_slot(provider);\n clear_provider_api_key_from_config(store, provider);\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"provider env config\");\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\"{slot:<12} {} {}\", yes_no(env), yes_no(file));\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into an explicit platform credential\n/// store. Hidden in v0.8.8 because the normal setup path is config/env only.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = provider_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n | ProviderKind::Fireworks\n | ProviderKind::Sglang\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, and SGLang providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"dispatcher\");\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n cmd.env(\"DEEPSEEK_API_KEY_SOURCE\", \"cli\");\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd.status().context(\"failed to spawn deepseek-tui\")?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(tui).args(args).status()?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_login_command_with_secrets(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n &secrets,\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-test\")\n );\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"fireworks\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Fireworks,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"sglang\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Sglang\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_shared_config_file() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-keyring\"));\n assert_eq!(\n store.config.providers.deepseek.api_key.as_deref(),\n Some(\"sk-keyring\")\n );\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(saved.contains(\"api_key = \\\"sk-keyring\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn logout_removes_plaintext_provider_keys() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-logout-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.fireworks.api_key = Some(\"fw-stale\".to_string());\n store.save().unwrap();\n\n let secrets = no_keyring_secrets();\n\n run_logout_command_with_secrets(&mut store, &secrets).expect(\"logout should succeed\");\n\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.fireworks.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\"completion\", vec![\"\", \"bash\"]),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "5bfc1feb62fceb926f5f6cb6f2cbc34d800b5623": {"t": 1785756892.1106286, "n": 64512, "h": "6a07b606379021c8", "c": "mod metrics;\nmod update;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a DeepSeek API key to the shared config.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n /// Check for and apply updates to the `deepseek` binary.\n Update,\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the OS keyring (never written to disk in\n /// plaintext). Reads from `--api-key`, `--api-key-stdin`, or\n /// prompts on stdin when neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the OS keyring (and from the\n /// plaintext config slot, if present, for parity).\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Migrate plaintext `api_key` values from `~/.deepseek/config.toml`\n /// into the OS keyring, then strip them from the file.\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n Some(Commands::Update) => update::run_update(),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key);\n if provider == ProviderKind::Deepseek {\n store.config.api_key = store.config.providers.deepseek.api_key.clone();\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek). This also updates the shared deepseek-tui config.\"\n );\n } else {\n println!(\"logged in using API key mode ({})\", provider.as_str());\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n store.config.api_key = None;\n store.config.providers.deepseek.api_key = None;\n store.config.providers.nvidia_nim.api_key = None;\n store.config.providers.openai.api_key = None;\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg\n/// in `security find-generic-password`).\nfn keyring_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 5] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Openai,\n];\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(keyring_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n println!(\"keyring backend: {}\", secrets.backend_name());\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let keyring_set = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\n \"{slot} auth: keyring={}, env={}, config={}\",\n keyring_set, env_set, file_set\n );\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n secrets\n .set(slot, &api_key)\n .with_context(|| format!(\"failed to write {slot} key to keyring\"))?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", secrets.backend_name());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n let in_keyring = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = secrets.resolve(slot).is_some() || in_file;\n if resolved {\n let source = if in_keyring {\n \"keyring\"\n } else if in_env {\n \"env\"\n } else {\n \"config-file\"\n };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n secrets\n .delete(slot)\n .with_context(|| format!(\"failed to delete {slot} key from keyring\"))?;\n // Also clear the plaintext slot in config.toml for parity.\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"keyring backend: {}\", secrets.backend_name());\n println!(\"provider keyring env config\");\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let kr = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\n \"{slot:<12} {} {} {}\",\n yes_no(kr),\n yes_no(env),\n yes_no(file)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the keyring. Stays\n/// idempotent: rerunning is a no-op once the file is clean.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, and Novita providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd.status().context(\"failed to spawn deepseek-tui\")?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(tui).args(args).status()?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_tui_compatible_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n\n run_login_command(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_keyring_and_not_to_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(\n inner.get(\"deepseek\").unwrap(),\n Some(\"sk-keyring\".to_string())\n );\n // Plaintext config slot must not be written.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(\n !saved.contains(\"sk-keyring\"),\n \"plaintext key leaked into config: {saved}\"\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_keyring_and_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\"completion\", vec![\"\", \"bash\"]),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}, "bb88ab912979fc958836b0730ac43c5ae34c4b94": {"t": 1785756897.2056062, "n": 64370, "h": "7ac1c23d919b3a98", "c": "mod metrics;\n\nuse std::io::{self, Read};\nuse std::net::SocketAddr;\nuse std::path::{Path, PathBuf};\nuse std::process::Command;\n\nuse anyhow::{Context, Result, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::{Shell, generate};\nuse deepseek_agent::ModelRegistry;\nuse deepseek_app_server::{\n AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,\n};\nuse deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};\nuse deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};\nuse deepseek_mcp::{McpServerDefinition, run_stdio_server};\nuse deepseek_secrets::Secrets;\nuse deepseek_state::{StateStore, ThreadListFilters};\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ProviderArg {\n Deepseek,\n NvidiaNim,\n Openai,\n Openrouter,\n Novita,\n}\n\nimpl From for ProviderKind {\n fn from(value: ProviderArg) -> Self {\n match value {\n ProviderArg::Deepseek => ProviderKind::Deepseek,\n ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,\n ProviderArg::Openai => ProviderKind::Openai,\n ProviderArg::Openrouter => ProviderKind::Openrouter,\n ProviderArg::Novita => ProviderKind::Novita,\n }\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n name = \"deepseek\",\n version,\n bin_name = \"deepseek\",\n override_usage = \"deepseek [OPTIONS] [PROMPT]\\n deepseek [OPTIONS] [ARGS]\"\n)]\nstruct Cli {\n #[arg(long)]\n config: Option,\n #[arg(long)]\n profile: Option,\n #[arg(\n long,\n value_enum,\n help = \"Advanced provider selector for non-TUI registry/config commands\"\n )]\n provider: Option,\n #[arg(long)]\n model: Option,\n #[arg(long = \"output-mode\")]\n output_mode: Option,\n #[arg(long = \"log-level\")]\n log_level: Option,\n #[arg(long)]\n telemetry: Option,\n #[arg(long)]\n approval_policy: Option,\n #[arg(long)]\n sandbox_mode: Option,\n #[arg(long)]\n api_key: Option,\n #[arg(long)]\n base_url: Option,\n #[arg(long = \"no-alt-screen\")]\n no_alt_screen: bool,\n #[arg(long = \"mouse-capture\", conflicts_with = \"no_mouse_capture\")]\n mouse_capture: bool,\n #[arg(long = \"no-mouse-capture\", conflicts_with = \"mouse_capture\")]\n no_mouse_capture: bool,\n #[arg(long = \"skip-onboarding\")]\n skip_onboarding: bool,\n #[arg(\n short = 'p',\n long = \"prompt\",\n value_name = \"PROMPT\",\n conflicts_with = \"prompt\"\n )]\n prompt_flag: Option,\n #[arg(value_name = \"PROMPT\")]\n prompt: Option,\n #[command(subcommand)]\n command: Option,\n}\n\n#[derive(Debug, Subcommand)]\nenum Commands {\n /// Run interactive/non-interactive flows via the TUI binary.\n Run(RunArgs),\n /// Run DeepSeek TUI diagnostics.\n Doctor(TuiPassthroughArgs),\n /// List live DeepSeek API models via the TUI binary.\n Models(TuiPassthroughArgs),\n /// List saved TUI sessions.\n Sessions(TuiPassthroughArgs),\n /// Resume a saved TUI session.\n Resume(TuiPassthroughArgs),\n /// Fork a saved TUI session.\n Fork(TuiPassthroughArgs),\n /// Create a default AGENTS.md in the current directory.\n Init(TuiPassthroughArgs),\n /// Bootstrap MCP config and/or skills directories.\n Setup(TuiPassthroughArgs),\n /// Run the DeepSeek TUI non-interactive agent command.\n Exec(TuiPassthroughArgs),\n /// Run a DeepSeek-powered code review over a git diff.\n Review(TuiPassthroughArgs),\n /// Apply a patch file or stdin to the working tree.\n Apply(TuiPassthroughArgs),\n /// Run the offline TUI evaluation harness.\n Eval(TuiPassthroughArgs),\n /// Manage TUI MCP servers.\n Mcp(TuiPassthroughArgs),\n /// Inspect TUI feature flags.\n Features(TuiPassthroughArgs),\n /// Run a local TUI server.\n Serve(TuiPassthroughArgs),\n /// Generate shell completions for the TUI binary.\n Completions(TuiPassthroughArgs),\n /// Save a DeepSeek API key to the shared config.\n Login(LoginArgs),\n /// Remove saved authentication state.\n Logout,\n /// Manage authentication credentials and provider mode.\n Auth(AuthArgs),\n /// Run MCP server mode over stdio.\n McpServer,\n /// Read/write/list config values.\n Config(ConfigArgs),\n /// Resolve or list available models across providers.\n Model(ModelArgs),\n /// Manage thread/session metadata and resume/fork flows.\n Thread(ThreadArgs),\n /// Evaluate sandbox/approval policy decisions.\n Sandbox(SandboxArgs),\n /// Run the app-server transport.\n AppServer(AppServerArgs),\n /// Generate shell completions.\n Completion {\n #[arg(value_enum)]\n shell: Shell,\n },\n /// Print a usage rollup from the audit log and session store.\n Metrics(MetricsArgs),\n}\n\n#[derive(Debug, Args)]\nstruct MetricsArgs {\n /// Emit machine-readable JSON.\n #[arg(long)]\n json: bool,\n /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).\n #[arg(long, value_name = \"DURATION\")]\n since: Option,\n}\n\n#[derive(Debug, Args)]\nstruct RunArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args, Clone)]\nstruct TuiPassthroughArgs {\n #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n args: Vec,\n}\n\n#[derive(Debug, Args)]\nstruct LoginArgs {\n #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]\n provider: ProviderArg,\n #[arg(long)]\n api_key: Option,\n #[arg(long, default_value_t = false, hide = true)]\n chatgpt: bool,\n #[arg(long, default_value_t = false, hide = true)]\n device_code: bool,\n #[arg(long, hide = true)]\n token: Option,\n}\n\n#[derive(Debug, Args)]\nstruct AuthArgs {\n #[command(subcommand)]\n command: AuthCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum AuthCommand {\n /// Show current provider, env vars, and config-file presence.\n Status,\n /// Save an API key to the OS keyring (never written to disk in\n /// plaintext). Reads from `--api-key`, `--api-key-stdin`, or\n /// prompts on stdin when neither is given. Does not echo the key.\n Set {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n /// Inline value (discouraged — appears in shell history).\n #[arg(long)]\n api_key: Option,\n /// Read the key from stdin instead of prompting.\n #[arg(long = \"api-key-stdin\", default_value_t = false)]\n api_key_stdin: bool,\n },\n /// Report whether a provider has a key configured. Never prints\n /// the value; just `set` / `not set` plus the source layer.\n Get {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// Delete a provider's key from the OS keyring (and from the\n /// plaintext config slot, if present, for parity).\n Clear {\n #[arg(long, value_enum)]\n provider: ProviderArg,\n },\n /// List all known providers with their auth state, without\n /// revealing keys.\n List,\n /// Migrate plaintext `api_key` values from `~/.deepseek/config.toml`\n /// into the OS keyring, then strip them from the file.\n Migrate {\n /// Don't actually write anything; print what would change.\n #[arg(long, default_value_t = false)]\n dry_run: bool,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ConfigArgs {\n #[command(subcommand)]\n command: ConfigCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n Get { key: String },\n Set { key: String, value: String },\n Unset { key: String },\n List,\n Path,\n}\n\n#[derive(Debug, Args)]\nstruct ModelArgs {\n #[command(subcommand)]\n command: ModelCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ModelCommand {\n List {\n #[arg(long, value_enum)]\n provider: Option,\n },\n Resolve {\n model: Option,\n #[arg(long, value_enum)]\n provider: Option,\n },\n}\n\n#[derive(Debug, Args)]\nstruct ThreadArgs {\n #[command(subcommand)]\n command: ThreadCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum ThreadCommand {\n List {\n #[arg(long, default_value_t = false)]\n all: bool,\n #[arg(long)]\n limit: Option,\n },\n Read {\n thread_id: String,\n },\n Resume {\n thread_id: String,\n },\n Fork {\n thread_id: String,\n },\n Archive {\n thread_id: String,\n },\n Unarchive {\n thread_id: String,\n },\n SetName {\n thread_id: String,\n name: String,\n },\n}\n\n#[derive(Debug, Args)]\nstruct SandboxArgs {\n #[command(subcommand)]\n command: SandboxCommand,\n}\n\n#[derive(Debug, Subcommand)]\nenum SandboxCommand {\n Check {\n command: String,\n #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]\n ask: ApprovalModeArg,\n },\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ApprovalModeArg {\n UnlessTrusted,\n OnFailure,\n OnRequest,\n Never,\n}\n\nimpl From for AskForApproval {\n fn from(value: ApprovalModeArg) -> Self {\n match value {\n ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,\n ApprovalModeArg::OnFailure => AskForApproval::OnFailure,\n ApprovalModeArg::OnRequest => AskForApproval::OnRequest,\n ApprovalModeArg::Never => AskForApproval::Never,\n }\n }\n}\n\n#[derive(Debug, Args)]\nstruct AppServerArgs {\n #[arg(long, default_value = \"127.0.0.1\")]\n host: String,\n #[arg(long, default_value_t = 8787)]\n port: u16,\n #[arg(long)]\n config: Option,\n #[arg(long, default_value_t = false)]\n stdio: bool,\n}\n\nconst MCP_SERVER_DEFINITIONS_KEY: &str = \"mcp.server_definitions\";\n\npub fn run_cli() -> std::process::ExitCode {\n match run() {\n Ok(()) => std::process::ExitCode::SUCCESS,\n Err(err) => {\n eprintln!(\"error: {err}\");\n std::process::ExitCode::FAILURE\n }\n }\n}\n\nfn run() -> Result<()> {\n let mut cli = Cli::parse();\n\n let mut store = ConfigStore::load(cli.config.clone())?;\n let runtime_overrides = CliRuntimeOverrides {\n provider: cli.provider.map(Into::into),\n model: cli.model.clone(),\n api_key: cli.api_key.clone(),\n base_url: cli.base_url.clone(),\n auth_mode: None,\n output_mode: cli.output_mode.clone(),\n log_level: cli.log_level.clone(),\n telemetry: cli.telemetry,\n approval_policy: cli.approval_policy.clone(),\n sandbox_mode: cli.sandbox_mode.clone(),\n };\n let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);\n\n let command = cli.command.take();\n\n match command {\n Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),\n Some(Commands::Doctor(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"doctor\", args))\n }\n Some(Commands::Models(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"models\", args))\n }\n Some(Commands::Sessions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"sessions\", args))\n }\n Some(Commands::Resume(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"resume\", args))\n }\n Some(Commands::Fork(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"fork\", args))\n }\n Some(Commands::Init(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"init\", args))\n }\n Some(Commands::Setup(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"setup\", args))\n }\n Some(Commands::Exec(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"exec\", args))\n }\n Some(Commands::Review(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"review\", args))\n }\n Some(Commands::Apply(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"apply\", args))\n }\n Some(Commands::Eval(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"eval\", args))\n }\n Some(Commands::Mcp(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"mcp\", args))\n }\n Some(Commands::Features(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"features\", args))\n }\n Some(Commands::Serve(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"serve\", args))\n }\n Some(Commands::Completions(args)) => {\n delegate_to_tui(&cli, &resolved_runtime, tui_args(\"completions\", args))\n }\n Some(Commands::Login(args)) => run_login_command(&mut store, args),\n Some(Commands::Logout) => run_logout_command(&mut store),\n Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),\n Some(Commands::McpServer) => run_mcp_server_command(&mut store),\n Some(Commands::Config(args)) => run_config_command(&mut store, args.command),\n Some(Commands::Model(args)) => run_model_command(args.command),\n Some(Commands::Thread(args)) => run_thread_command(args.command),\n Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),\n Some(Commands::AppServer(args)) => run_app_server_command(args),\n Some(Commands::Completion { shell }) => {\n let mut cmd = Cli::command();\n generate(shell, &mut cmd, \"deepseek\", &mut io::stdout());\n Ok(())\n }\n Some(Commands::Metrics(args)) => run_metrics_command(args),\n None => {\n let mut forwarded = Vec::new();\n if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {\n forwarded.push(\"--prompt\".to_string());\n forwarded.push(prompt);\n }\n delegate_to_tui(&cli, &resolved_runtime, forwarded)\n }\n }\n}\n\nfn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec {\n let mut forwarded = Vec::with_capacity(args.args.len() + 1);\n forwarded.push(command.to_string());\n forwarded.extend(args.args);\n forwarded\n}\n\nfn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {\n let provider: ProviderKind = args.provider.into();\n store.config.provider = provider;\n\n if args.chatgpt {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"chatgpt\".to_string());\n store.config.chatgpt_access_token = Some(token);\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged in using chatgpt token mode ({})\", provider.as_str());\n return Ok(());\n }\n\n if args.device_code {\n let token = match args.token {\n Some(token) => token,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"device_code\".to_string());\n store.config.device_code_session = Some(token);\n store.config.chatgpt_access_token = None;\n store.save()?;\n println!(\n \"logged in using device code session mode ({})\",\n provider.as_str()\n );\n return Ok(());\n }\n\n let api_key = match args.api_key {\n Some(v) => v,\n None => read_api_key_from_stdin()?,\n };\n store.config.auth_mode = Some(\"api_key\".to_string());\n store.config.providers.for_provider_mut(provider).api_key = Some(api_key);\n if provider == ProviderKind::Deepseek {\n store.config.api_key = store.config.providers.deepseek.api_key.clone();\n if store.config.default_text_model.is_none() {\n store.config.default_text_model = Some(\n store\n .config\n .providers\n .deepseek\n .model\n .clone()\n .unwrap_or_else(|| \"deepseek-v4-pro\".to_string()),\n );\n }\n }\n store.save()?;\n if provider == ProviderKind::Deepseek {\n println!(\n \"logged in using API key mode (deepseek). This also updates the shared deepseek-tui config.\"\n );\n } else {\n println!(\"logged in using API key mode ({})\", provider.as_str());\n }\n Ok(())\n}\n\nfn run_logout_command(store: &mut ConfigStore) -> Result<()> {\n store.config.api_key = None;\n store.config.providers.deepseek.api_key = None;\n store.config.providers.nvidia_nim.api_key = None;\n store.config.providers.openai.api_key = None;\n store.config.auth_mode = None;\n store.config.chatgpt_access_token = None;\n store.config.device_code_session = None;\n store.save()?;\n println!(\"logged out\");\n Ok(())\n}\n\n/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg\n/// in `security find-generic-password`).\nfn keyring_slot(provider: ProviderKind) -> &'static str {\n match provider {\n ProviderKind::Deepseek => \"deepseek\",\n ProviderKind::NvidiaNim => \"nvidia-nim\",\n ProviderKind::Openai => \"openai\",\n ProviderKind::Openrouter => \"openrouter\",\n ProviderKind::Novita => \"novita\",\n }\n}\n\n/// Provider order used by the `auth list` and `auth status` outputs.\nconst PROVIDER_LIST: [ProviderKind; 5] = [\n ProviderKind::Deepseek,\n ProviderKind::NvidiaNim,\n ProviderKind::Openrouter,\n ProviderKind::Novita,\n ProviderKind::Openai,\n];\n\nfn provider_env_set(provider: ProviderKind) -> bool {\n deepseek_secrets::env_for(keyring_slot(provider)).is_some()\n}\n\nfn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {\n let slot = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .as_ref();\n let root = (provider == ProviderKind::Deepseek)\n .then_some(store.config.api_key.as_ref())\n .flatten();\n slot.or(root).is_some_and(|v| !v.trim().is_empty())\n}\n\nfn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {\n run_auth_command_with_secrets(store, command, &Secrets::auto_detect())\n}\n\nfn run_auth_command_with_secrets(\n store: &mut ConfigStore,\n command: AuthCommand,\n secrets: &Secrets,\n) -> Result<()> {\n match command {\n AuthCommand::Status => {\n println!(\"provider: {}\", store.config.provider.as_str());\n println!(\"keyring backend: {}\", secrets.backend_name());\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let keyring_set = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let env_set = provider_env_set(provider);\n let file_set = provider_config_set(store, provider);\n println!(\n \"{slot} auth: keyring={}, env={}, config={}\",\n keyring_set, env_set, file_set\n );\n }\n Ok(())\n }\n AuthCommand::Set {\n provider,\n api_key,\n api_key_stdin,\n } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n let api_key = match (api_key, api_key_stdin) {\n (Some(v), _) => v,\n (None, true) => read_api_key_from_stdin()?,\n (None, false) => prompt_api_key(slot)?,\n };\n secrets\n .set(slot, &api_key)\n .with_context(|| format!(\"failed to write {slot} key to keyring\"))?;\n // Don't print the key. Don't echo length.\n println!(\"saved API key for {slot} to {}\", secrets.backend_name());\n Ok(())\n }\n AuthCommand::Get { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n let in_keyring = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let in_env = provider_env_set(provider);\n let in_file = provider_config_set(store, provider);\n // Report the highest-priority source that has it.\n let resolved = secrets.resolve(slot).is_some() || in_file;\n if resolved {\n let source = if in_keyring {\n \"keyring\"\n } else if in_env {\n \"env\"\n } else {\n \"config-file\"\n };\n println!(\"{slot}: set (source: {source})\");\n } else {\n println!(\"{slot}: not set\");\n }\n Ok(())\n }\n AuthCommand::Clear { provider } => {\n let provider: ProviderKind = provider.into();\n let slot = keyring_slot(provider);\n secrets\n .delete(slot)\n .with_context(|| format!(\"failed to delete {slot} key from keyring\"))?;\n // Also clear the plaintext slot in config.toml for parity.\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n store.save()?;\n println!(\"cleared API key for {slot}\");\n Ok(())\n }\n AuthCommand::List => {\n println!(\"keyring backend: {}\", secrets.backend_name());\n println!(\"provider keyring env config\");\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let kr = secrets\n .get(slot)\n .ok()\n .flatten()\n .is_some_and(|v| !v.trim().is_empty());\n let env = provider_env_set(provider);\n let file = provider_config_set(store, provider);\n println!(\n \"{slot:<12} {} {} {}\",\n yes_no(kr),\n yes_no(env),\n yes_no(file)\n );\n }\n Ok(())\n }\n AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),\n }\n}\n\nfn yes_no(b: bool) -> &'static str {\n if b { \"yes\" } else { \"no \" }\n}\n\nfn prompt_api_key(slot: &str) -> Result {\n use std::io::{IsTerminal, Write};\n eprint!(\"Enter API key for {slot}: \");\n io::stderr().flush().ok();\n if !io::stdin().is_terminal() {\n // Non-interactive: read directly without prompting twice.\n return read_api_key_from_stdin();\n }\n let mut buf = String::new();\n io::stdin()\n .read_line(&mut buf)\n .context(\"failed to read API key from stdin\")?;\n let key = buf.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n/// Move plaintext keys from config.toml into the keyring. Stays\n/// idempotent: rerunning is a no-op once the file is clean.\nfn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {\n let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();\n let mut warnings: Vec = Vec::new();\n\n for provider in PROVIDER_LIST {\n let slot = keyring_slot(provider);\n let from_provider_block = store\n .config\n .providers\n .for_provider(provider)\n .api_key\n .clone()\n .filter(|v| !v.trim().is_empty());\n let from_root = (provider == ProviderKind::Deepseek)\n .then(|| store.config.api_key.clone())\n .flatten()\n .filter(|v| !v.trim().is_empty());\n let value = from_provider_block.or(from_root);\n let Some(value) = value else { continue };\n\n if let Ok(Some(existing)) = secrets.get(slot)\n && existing == value\n {\n // Already migrated; safe to strip the file slot.\n } else if dry_run {\n migrated.push((provider, slot));\n continue;\n } else if let Err(err) = secrets.set(slot, &value) {\n warnings.push(format!(\"skipped {slot}: failed to write to keyring: {err}\"));\n continue;\n }\n if !dry_run {\n store.config.providers.for_provider_mut(provider).api_key = None;\n if provider == ProviderKind::Deepseek {\n store.config.api_key = None;\n }\n }\n migrated.push((provider, slot));\n }\n\n if !dry_run && !migrated.is_empty() {\n store\n .save()\n .context(\"failed to write updated config.toml\")?;\n }\n\n println!(\"keyring backend: {}\", secrets.backend_name());\n if migrated.is_empty() {\n println!(\"nothing to migrate (config.toml has no plaintext api_key entries)\");\n } else {\n println!(\n \"{} {} provider key(s):\",\n if dry_run { \"would migrate\" } else { \"migrated\" },\n migrated.len()\n );\n for (_, slot) in &migrated {\n println!(\" - {slot}\");\n }\n if !dry_run {\n println!(\n \"config.toml at {} no longer contains api_key entries for migrated providers.\",\n store.path().display()\n );\n }\n }\n for w in warnings {\n eprintln!(\"warning: {w}\");\n }\n Ok(())\n}\n\nfn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {\n match command {\n ConfigCommand::Get { key } => {\n if let Some(value) = store.config.get_value(&key) {\n println!(\"{value}\");\n return Ok(());\n }\n bail!(\"key not found: {key}\");\n }\n ConfigCommand::Set { key, value } => {\n store.config.set_value(&key, &value)?;\n store.save()?;\n println!(\"set {key}\");\n Ok(())\n }\n ConfigCommand::Unset { key } => {\n store.config.unset_value(&key)?;\n store.save()?;\n println!(\"unset {key}\");\n Ok(())\n }\n ConfigCommand::List => {\n for (key, value) in store.config.list_values() {\n println!(\"{key} = {value}\");\n }\n Ok(())\n }\n ConfigCommand::Path => {\n println!(\"{}\", store.path().display());\n Ok(())\n }\n }\n}\n\nfn run_model_command(command: ModelCommand) -> Result<()> {\n let registry = ModelRegistry::default();\n match command {\n ModelCommand::List { provider } => {\n let filter = provider.map(ProviderKind::from);\n for model in registry.list().into_iter().filter(|m| match filter {\n Some(p) => m.provider == p,\n None => true,\n }) {\n println!(\"{} ({})\", model.id, model.provider.as_str());\n }\n Ok(())\n }\n ModelCommand::Resolve { model, provider } => {\n let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));\n println!(\"requested: {}\", resolved.requested.unwrap_or_default());\n println!(\"resolved: {}\", resolved.resolved.id);\n println!(\"provider: {}\", resolved.resolved.provider.as_str());\n println!(\"used_fallback: {}\", resolved.used_fallback);\n Ok(())\n }\n }\n}\n\nfn run_thread_command(command: ThreadCommand) -> Result<()> {\n let state = StateStore::open(None)?;\n match command {\n ThreadCommand::List { all, limit } => {\n let threads = state.list_threads(ThreadListFilters {\n include_archived: all,\n limit,\n })?;\n for thread in threads {\n println!(\n \"{} | {} | {} | {}\",\n thread.id,\n thread\n .name\n .clone()\n .unwrap_or_else(|| \"(unnamed)\".to_string()),\n thread.model_provider,\n thread.cwd.display()\n );\n }\n Ok(())\n }\n ThreadCommand::Read { thread_id } => {\n let thread = state.get_thread(&thread_id)?;\n println!(\"{}\", serde_json::to_string_pretty(&thread)?);\n Ok(())\n }\n ThreadCommand::Resume { thread_id } => {\n let args = vec![\"resume\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Fork { thread_id } => {\n let args = vec![\"fork\".to_string(), thread_id];\n delegate_simple_tui(args)\n }\n ThreadCommand::Archive { thread_id } => {\n state.mark_archived(&thread_id)?;\n println!(\"archived {thread_id}\");\n Ok(())\n }\n ThreadCommand::Unarchive { thread_id } => {\n state.mark_unarchived(&thread_id)?;\n println!(\"unarchived {thread_id}\");\n Ok(())\n }\n ThreadCommand::SetName { thread_id, name } => {\n let mut thread = state\n .get_thread(&thread_id)?\n .with_context(|| format!(\"thread not found: {thread_id}\"))?;\n thread.name = Some(name);\n thread.updated_at = chrono::Utc::now().timestamp();\n state.upsert_thread(&thread)?;\n println!(\"renamed {thread_id}\");\n Ok(())\n }\n }\n}\n\nfn run_sandbox_command(command: SandboxCommand) -> Result<()> {\n match command {\n SandboxCommand::Check { command, ask } => {\n let engine = ExecPolicyEngine::new(Vec::new(), vec![\"rm -rf\".to_string()]);\n let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(\".\"));\n let decision = engine.check(ExecPolicyContext {\n command: &command,\n cwd: &cwd.display().to_string(),\n ask_for_approval: ask.into(),\n sandbox_mode: Some(\"workspace-write\"),\n })?;\n println!(\"{}\", serde_json::to_string_pretty(&decision)?);\n Ok(())\n }\n }\n}\n\nfn run_app_server_command(args: AppServerArgs) -> Result<()> {\n let runtime = tokio::runtime::Builder::new_multi_thread()\n .enable_all()\n .build()\n .context(\"failed to create tokio runtime\")?;\n if args.stdio {\n return runtime.block_on(run_app_server_stdio(args.config));\n }\n let listen: SocketAddr = format!(\"{}:{}\", args.host, args.port)\n .parse()\n .with_context(|| {\n format!(\n \"invalid app-server listen address {}:{}\",\n args.host, args.port\n )\n })?;\n runtime.block_on(run_app_server(AppServerOptions {\n listen,\n config_path: args.config,\n }))\n}\n\nfn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {\n let persisted = load_mcp_server_definitions(store);\n let updated = run_stdio_server(persisted)?;\n persist_mcp_server_definitions(store, &updated)\n}\n\nfn load_mcp_server_definitions(store: &ConfigStore) -> Vec {\n let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {\n return Vec::new();\n };\n\n match parse_mcp_server_definitions(&raw) {\n Ok(definitions) => definitions,\n Err(err) => {\n eprintln!(\n \"warning: failed to parse persisted MCP server definitions ({}): {}\",\n MCP_SERVER_DEFINITIONS_KEY, err\n );\n Vec::new()\n }\n }\n}\n\nfn parse_mcp_server_definitions(raw: &str) -> Result> {\n if let Ok(parsed) = serde_json::from_str::>(raw) {\n return Ok(parsed);\n }\n\n let unwrapped: String = serde_json::from_str(raw)\n .with_context(|| format!(\"invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}\"))?;\n serde_json::from_str::>(&unwrapped).with_context(|| {\n format!(\"invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}\")\n })\n}\n\nfn persist_mcp_server_definitions(\n store: &mut ConfigStore,\n definitions: &[McpServerDefinition],\n) -> Result<()> {\n let encoded =\n serde_json::to_string(definitions).context(\"failed to encode MCP server definitions\")?;\n store\n .config\n .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;\n store.save()\n}\n\nfn delegate_to_tui(\n cli: &Cli,\n resolved_runtime: &ResolvedRuntimeOptions,\n passthrough: Vec,\n) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n\n let mut cmd = Command::new(tui);\n if let Some(config) = cli.config.as_ref() {\n cmd.arg(\"--config\").arg(config);\n }\n if let Some(profile) = cli.profile.as_ref() {\n cmd.arg(\"--profile\").arg(profile);\n }\n if cli.no_alt_screen {\n cmd.arg(\"--no-alt-screen\");\n }\n if cli.mouse_capture {\n cmd.arg(\"--mouse-capture\");\n }\n if cli.no_mouse_capture {\n cmd.arg(\"--no-mouse-capture\");\n }\n if cli.skip_onboarding {\n cmd.arg(\"--skip-onboarding\");\n }\n cmd.args(passthrough);\n\n if !matches!(\n resolved_runtime.provider,\n ProviderKind::Deepseek\n | ProviderKind::NvidiaNim\n | ProviderKind::Openrouter\n | ProviderKind::Novita\n ) {\n bail!(\n \"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, and Novita providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.\",\n resolved_runtime.provider.as_str()\n );\n }\n\n cmd.env(\"DEEPSEEK_MODEL\", &resolved_runtime.model);\n cmd.env(\"DEEPSEEK_BASE_URL\", &resolved_runtime.base_url);\n cmd.env(\"DEEPSEEK_PROVIDER\", resolved_runtime.provider.as_str());\n if let Some(api_key) = resolved_runtime.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n }\n\n if let Some(model) = cli.model.as_ref() {\n cmd.env(\"DEEPSEEK_MODEL\", model);\n }\n if let Some(output_mode) = cli.output_mode.as_ref() {\n cmd.env(\"DEEPSEEK_OUTPUT_MODE\", output_mode);\n }\n if let Some(log_level) = cli.log_level.as_ref() {\n cmd.env(\"DEEPSEEK_LOG_LEVEL\", log_level);\n }\n if let Some(telemetry) = cli.telemetry {\n cmd.env(\"DEEPSEEK_TELEMETRY\", telemetry.to_string());\n }\n if let Some(policy) = cli.approval_policy.as_ref() {\n cmd.env(\"DEEPSEEK_APPROVAL_POLICY\", policy);\n }\n if let Some(mode) = cli.sandbox_mode.as_ref() {\n cmd.env(\"DEEPSEEK_SANDBOX_MODE\", mode);\n }\n if let Some(api_key) = cli.api_key.as_ref() {\n cmd.env(\"DEEPSEEK_API_KEY\", api_key);\n }\n if let Some(base_url) = cli.base_url.as_ref() {\n cmd.env(\"DEEPSEEK_BASE_URL\", base_url);\n }\n\n let status = cmd.status().context(\"failed to spawn deepseek-tui\")?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\nfn delegate_simple_tui(args: Vec) -> Result<()> {\n let tui = locate_sibling_tui_binary()?;\n let status = Command::new(tui).args(args).status()?;\n match status.code() {\n Some(code) => std::process::exit(code),\n None => bail!(\"deepseek-tui terminated by signal\"),\n }\n}\n\n/// Resolve the sibling `deepseek-tui` executable next to the running\n/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so\n/// the npm-distributed Windows package — which ships\n/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).\n///\n/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for\n/// custom installs and CI test layouts. On Windows we additionally try\n/// the suffix-less name as a fallback for users who already manually\n/// renamed the file before this fix landed.\nfn locate_sibling_tui_binary() -> Result {\n if let Ok(override_path) = std::env::var(\"DEEPSEEK_TUI_BIN\") {\n let candidate = PathBuf::from(override_path);\n if candidate.is_file() {\n return Ok(candidate);\n }\n bail!(\n \"DEEPSEEK_TUI_BIN points at {}, which is not a regular file.\",\n candidate.display()\n );\n }\n\n let current = std::env::current_exe().context(\"failed to locate current executable path\")?;\n if let Some(found) = sibling_tui_candidate(¤t) {\n return Ok(found);\n }\n\n // Build a stable error path so the user sees the platform-correct\n // expected name, not \"deepseek-tui\" on Windows.\n let expected = current.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n bail!(\n \"Companion `deepseek-tui` binary not found at {}.\\n\\\n\\n\\\nThe `deepseek` dispatcher delegates interactive sessions to a sibling \\\n`deepseek-tui` binary. To fix this, install one of:\\n\\\n • npm: npm install -g deepseek-tui (downloads both binaries)\\n\\\n • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\\n\\\n • GitHub Releases: download BOTH `deepseek-` AND \\\n`deepseek-tui-` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \\\nand place them in the same directory.\\n\\\n\\n\\\nOr set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.\",\n expected.display()\n );\n}\n\n/// Return the first existing sibling-binary path under any of the names\n/// `deepseek-tui` might use on this platform. Pure function to keep\n/// `locate_sibling_tui_binary` testable.\nfn sibling_tui_candidate(dispatcher: &Path) -> Option {\n // Primary: platform-correct name. EXE_SUFFIX is \"\" on Unix and \".exe\"\n // on Windows.\n let primary =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n if primary.is_file() {\n return Some(primary);\n }\n // Windows fallback: a user who manually renamed `.exe` away (per the\n // workaround in #247) still launches successfully under the new code.\n if cfg!(windows) {\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n if suffixless.is_file() {\n return Some(suffixless);\n }\n }\n None\n}\n\nfn run_metrics_command(args: MetricsArgs) -> Result<()> {\n let since = match args.since.as_deref() {\n Some(s) => {\n Some(metrics::parse_since(s).with_context(|| format!(\"invalid --since value: {s:?}\"))?)\n }\n None => None,\n };\n metrics::run(metrics::MetricsArgs {\n json: args.json,\n since,\n })\n}\n\nfn read_api_key_from_stdin() -> Result {\n let mut input = String::new();\n io::stdin()\n .read_to_string(&mut input)\n .context(\"failed to read api key from stdin\")?;\n let key = input.trim().to_string();\n if key.is_empty() {\n bail!(\"empty API key provided\");\n }\n Ok(key)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use clap::error::ErrorKind;\n\n fn parse_ok(argv: &[&str]) -> Cli {\n Cli::try_parse_from(argv).unwrap_or_else(|err| panic!(\"parse failed for {argv:?}: {err}\"))\n }\n\n fn help_for(argv: &[&str]) -> String {\n let err = Cli::try_parse_from(argv).expect_err(\"expected --help to short-circuit parsing\");\n assert_eq!(err.kind(), ErrorKind::DisplayHelp);\n err.to_string()\n }\n\n #[test]\n fn clap_command_definition_is_consistent() {\n Cli::command().debug_assert();\n }\n\n #[test]\n fn parses_config_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"config\", \"get\", \"provider\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Get { ref key }\n })) if key == \"provider\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"set\", \"model\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Set { ref key, ref value }\n })) if key == \"model\" && value == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"config\", \"unset\", \"model\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Unset { ref key }\n })) if key == \"model\"\n ));\n\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"list\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::List\n }))\n ));\n assert!(matches!(\n parse_ok(&[\"deepseek\", \"config\", \"path\"]).command,\n Some(Commands::Config(ConfigArgs {\n command: ConfigCommand::Path\n }))\n ));\n }\n\n #[test]\n fn parses_model_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List { provider: None }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"list\", \"--provider\", \"openai\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::List {\n provider: Some(ProviderArg::Openai)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"model\", \"resolve\", \"deepseek-v4-flash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: None\n }\n })) if model == \"deepseek-v4-flash\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"model\",\n \"resolve\",\n \"--provider\",\n \"deepseek\",\n \"deepseek-v4-pro\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Model(ModelArgs {\n command: ModelCommand::Resolve {\n model: Some(ref model),\n provider: Some(ProviderArg::Deepseek)\n }\n })) if model == \"deepseek-v4-pro\"\n ));\n }\n\n #[test]\n fn parses_thread_command_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"list\", \"--all\", \"--limit\", \"50\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::List {\n all: true,\n limit: Some(50)\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"read\", \"thread-1\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Read { ref thread_id }\n })) if thread_id == \"thread-1\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"resume\", \"thread-2\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Resume { ref thread_id }\n })) if thread_id == \"thread-2\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"fork\", \"thread-3\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Fork { ref thread_id }\n })) if thread_id == \"thread-3\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"archive\", \"thread-4\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Archive { ref thread_id }\n })) if thread_id == \"thread-4\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"unarchive\", \"thread-5\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::Unarchive { ref thread_id }\n })) if thread_id == \"thread-5\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"thread\", \"set-name\", \"thread-6\", \"My Thread\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Thread(ThreadArgs {\n command: ThreadCommand::SetName {\n ref thread_id,\n ref name\n }\n })) if thread_id == \"thread-6\" && name == \"My Thread\"\n ));\n }\n\n #[test]\n fn parses_sandbox_app_server_and_completion_matrix() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"sandbox\",\n \"check\",\n \"echo hello\",\n \"--ask\",\n \"on-failure\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Sandbox(SandboxArgs {\n command: SandboxCommand::Check {\n ref command,\n ask: ApprovalModeArg::OnFailure\n }\n })) if command == \"echo hello\"\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"app-server\",\n \"--host\",\n \"0.0.0.0\",\n \"--port\",\n \"9999\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs {\n ref host,\n port: 9999,\n stdio: false,\n ..\n })) if host == \"0.0.0.0\"\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"app-server\", \"--stdio\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"completion\", \"bash\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Completion { shell: Shell::Bash })\n ));\n }\n\n #[test]\n fn parses_direct_tui_command_aliases() {\n let cli = parse_ok(&[\"deepseek\", \"doctor\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"models\", \"--json\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &[\"--json\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"resume\", \"abc123\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &[\"abc123\"]\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"setup\", \"--skills\", \"--local\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Setup(TuiPassthroughArgs { ref args }))\n if args == &[\"--skills\", \"--local\"]\n ));\n }\n\n #[test]\n fn deepseek_login_writes_tui_compatible_config() {\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-login-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n\n run_login_command(\n &mut store,\n LoginArgs {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-test\".to_string()),\n chatgpt: false,\n device_code: false,\n token: None,\n },\n )\n .expect(\"login should write config\");\n\n assert_eq!(store.config.api_key.as_deref(), Some(\"sk-test\"));\n assert_eq!(\n store.config.default_text_model.as_deref(),\n Some(\"deepseek-v4-pro\")\n );\n let saved = std::fs::read_to_string(&path).expect(\"config should be written\");\n assert!(saved.contains(\"api_key = \\\"sk-test\\\"\"));\n assert!(saved.contains(\"default_text_model = \\\"deepseek-v4-pro\\\"\"));\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_auth_subcommand_matrix() {\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"set\", \"--provider\", \"deepseek\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: None,\n api_key_stdin: false,\n }\n }))\n ));\n\n let cli = parse_ok(&[\n \"deepseek\",\n \"auth\",\n \"set\",\n \"--provider\",\n \"openrouter\",\n \"--api-key-stdin\",\n ]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Set {\n provider: ProviderArg::Openrouter,\n api_key: None,\n api_key_stdin: true,\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"get\", \"--provider\", \"novita\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Get {\n provider: ProviderArg::Novita\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"clear\", \"--provider\", \"nvidia-nim\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Clear {\n provider: ProviderArg::NvidiaNim\n }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"list\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::List\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: false }\n }))\n ));\n\n let cli = parse_ok(&[\"deepseek\", \"auth\", \"migrate\", \"--dry-run\"]);\n assert!(matches!(\n cli.command,\n Some(Commands::Auth(AuthArgs {\n command: AuthCommand::Migrate { dry_run: true }\n }))\n ));\n }\n\n #[test]\n fn auth_set_writes_to_keyring_and_not_to_config_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-set-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Set {\n provider: ProviderArg::Deepseek,\n api_key: Some(\"sk-keyring\".to_string()),\n api_key_stdin: false,\n },\n &secrets,\n )\n .expect(\"set should succeed\");\n\n assert_eq!(\n inner.get(\"deepseek\").unwrap(),\n Some(\"sk-keyring\".to_string())\n );\n // Plaintext config slot must not be written.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n let saved = std::fs::read_to_string(&path).unwrap_or_default();\n assert!(\n !saved.contains(\"sk-keyring\"),\n \"plaintext key leaked into config: {saved}\"\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_clear_removes_from_keyring_and_config() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-clear-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-stale\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-stale\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n inner.set(\"deepseek\", \"sk-keyring\").unwrap();\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Clear {\n provider: ProviderArg::Deepseek,\n },\n &secrets,\n )\n .expect(\"clear should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), None);\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-test-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.deepseek.api_key = Some(\"sk-deep\".to_string());\n store.config.providers.openrouter.api_key = Some(\"or-key\".to_string());\n store.config.providers.novita.api_key = Some(\"nv-key\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(\n &mut store,\n AuthCommand::Migrate { dry_run: false },\n &secrets,\n )\n .expect(\"migrate should succeed\");\n\n assert_eq!(inner.get(\"deepseek\").unwrap(), Some(\"sk-deep\".to_string()));\n assert_eq!(inner.get(\"openrouter\").unwrap(), Some(\"or-key\".to_string()));\n assert_eq!(inner.get(\"novita\").unwrap(), Some(\"nv-key\".to_string()));\n\n // Config file must no longer contain the api keys.\n assert!(store.config.api_key.is_none());\n assert!(store.config.providers.deepseek.api_key.is_none());\n assert!(store.config.providers.openrouter.api_key.is_none());\n assert!(store.config.providers.novita.api_key.is_none());\n\n let saved = std::fs::read_to_string(&path).expect(\"config exists post-migrate\");\n assert!(!saved.contains(\"sk-deep\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"or-key\"), \"plaintext leaked: {saved}\");\n assert!(!saved.contains(\"nv-key\"), \"plaintext leaked: {saved}\");\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn auth_migrate_dry_run_does_not_modify_anything() {\n use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};\n use std::sync::Arc;\n\n let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();\n let path = std::env::temp_dir().join(format!(\n \"deepseek-cli-auth-migrate-dry-{}-{nanos}.toml\",\n std::process::id()\n ));\n let mut store = ConfigStore::load(Some(path.clone())).expect(\"store should load\");\n store.config.providers.openrouter.api_key = Some(\"or-stay\".to_string());\n store.save().unwrap();\n\n let inner = Arc::new(InMemoryKeyringStore::new());\n let secrets = Secrets::new(inner.clone());\n\n run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)\n .expect(\"dry-run should succeed\");\n\n assert_eq!(inner.get(\"openrouter\").unwrap(), None);\n assert_eq!(\n store.config.providers.openrouter.api_key.as_deref(),\n Some(\"or-stay\")\n );\n\n let _ = std::fs::remove_file(path);\n }\n\n #[test]\n fn parses_global_override_flags() {\n let cli = parse_ok(&[\n \"deepseek\",\n \"--provider\",\n \"openai\",\n \"--config\",\n \"/tmp/deepseek.toml\",\n \"--profile\",\n \"work\",\n \"--model\",\n \"gpt-4.1\",\n \"--output-mode\",\n \"json\",\n \"--log-level\",\n \"debug\",\n \"--telemetry\",\n \"true\",\n \"--approval-policy\",\n \"on-request\",\n \"--sandbox-mode\",\n \"workspace-write\",\n \"--base-url\",\n \"https://api.openai.com/v1\",\n \"--api-key\",\n \"sk-test\",\n \"--no-alt-screen\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"model\",\n \"resolve\",\n \"gpt-4.1\",\n ]);\n\n assert!(matches!(cli.provider, Some(ProviderArg::Openai)));\n assert_eq!(cli.config, Some(PathBuf::from(\"/tmp/deepseek.toml\")));\n assert_eq!(cli.profile.as_deref(), Some(\"work\"));\n assert_eq!(cli.model.as_deref(), Some(\"gpt-4.1\"));\n assert_eq!(cli.output_mode.as_deref(), Some(\"json\"));\n assert_eq!(cli.log_level.as_deref(), Some(\"debug\"));\n assert_eq!(cli.telemetry, Some(true));\n assert_eq!(cli.approval_policy.as_deref(), Some(\"on-request\"));\n assert_eq!(cli.sandbox_mode.as_deref(), Some(\"workspace-write\"));\n assert_eq!(cli.base_url.as_deref(), Some(\"https://api.openai.com/v1\"));\n assert_eq!(cli.api_key.as_deref(), Some(\"sk-test\"));\n assert!(cli.no_alt_screen);\n assert!(cli.no_mouse_capture);\n assert!(!cli.mouse_capture);\n assert!(cli.skip_onboarding);\n }\n\n #[test]\n fn parses_top_level_prompt_flag_for_canonical_one_shot() {\n let cli = parse_ok(&[\"deepseek\", \"-p\", \"Reply with exactly OK.\"]);\n\n assert_eq!(cli.prompt_flag.as_deref(), Some(\"Reply with exactly OK.\"));\n assert_eq!(cli.prompt, None);\n }\n\n #[test]\n fn root_help_surface_contains_expected_subcommands_and_globals() {\n let rendered = help_for(&[\"deepseek\", \"--help\"]);\n\n for token in [\n \"run\",\n \"doctor\",\n \"models\",\n \"sessions\",\n \"resume\",\n \"setup\",\n \"login\",\n \"logout\",\n \"auth\",\n \"mcp-server\",\n \"config\",\n \"model\",\n \"thread\",\n \"sandbox\",\n \"app-server\",\n \"completion\",\n \"metrics\",\n \"--provider\",\n \"--model\",\n \"--config\",\n \"--profile\",\n \"--output-mode\",\n \"--log-level\",\n \"--telemetry\",\n \"--base-url\",\n \"--api-key\",\n \"--approval-policy\",\n \"--sandbox-mode\",\n \"--no-alt-screen\",\n \"--mouse-capture\",\n \"--no-mouse-capture\",\n \"--skip-onboarding\",\n \"--prompt\",\n ] {\n assert!(\n rendered.contains(token),\n \"expected help to contain token: {token}\"\n );\n }\n }\n\n #[test]\n fn subcommand_help_surfaces_are_stable() {\n let cases = [\n (\"config\", vec![\"get\", \"set\", \"unset\", \"list\", \"path\"]),\n (\"model\", vec![\"list\", \"resolve\"]),\n (\n \"thread\",\n vec![\n \"list\",\n \"read\",\n \"resume\",\n \"fork\",\n \"archive\",\n \"unarchive\",\n \"set-name\",\n ],\n ),\n (\"sandbox\", vec![\"check\"]),\n (\n \"app-server\",\n vec![\"--host\", \"--port\", \"--config\", \"--stdio\"],\n ),\n (\"completion\", vec![\"\", \"bash\"]),\n (\"metrics\", vec![\"--json\", \"--since\"]),\n ];\n\n for (subcommand, expected_tokens) in cases {\n let argv = [\"deepseek\", subcommand, \"--help\"];\n let rendered = help_for(&argv);\n for token in expected_tokens {\n assert!(\n rendered.contains(token),\n \"expected help for `{subcommand}` to include `{token}`\"\n );\n }\n }\n }\n\n /// Regression for issue #247: on Windows the dispatcher must find the\n /// sibling `deepseek-tui.exe`, not bail out looking for an\n /// extension-less `deepseek-tui`. The candidate resolver also accepts\n /// the suffix-less name on Windows so users who manually renamed the\n /// file as a workaround keep working after the upgrade.\n #[test]\n fn sibling_tui_candidate_picks_platform_correct_name() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir\n .path()\n .join(\"deepseek\")\n .with_extension(std::env::consts::EXE_EXTENSION);\n // Touch the dispatcher so its parent dir is the lookup root.\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // No sibling yet — resolver returns None.\n assert!(sibling_tui_candidate(&dispatcher).is_none());\n\n let target =\n dispatcher.with_file_name(format!(\"deepseek-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&target, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher).expect(\"must locate sibling\");\n assert_eq!(found, target, \"primary platform-correct name wins\");\n }\n\n /// Windows-only fallback: the user from #247 manually renamed the\n /// file to drop `.exe`. After the fix lands, that workaround must\n /// still resolve via the suffix-less fallback so they don't have to\n /// rename it back.\n #[cfg(windows)]\n #[test]\n fn sibling_tui_candidate_windows_falls_back_to_suffixless() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let dispatcher = dir.path().join(\"deepseek.exe\");\n std::fs::write(&dispatcher, b\"\").unwrap();\n\n // Only the suffixless name exists — emulates the manual rename.\n let suffixless = dispatcher.with_file_name(\"deepseek-tui\");\n std::fs::write(&suffixless, b\"\").unwrap();\n\n let found = sibling_tui_candidate(&dispatcher)\n .expect(\"Windows fallback must locate suffixless deepseek-tui\");\n assert_eq!(found, suffixless);\n }\n\n /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for\n /// custom Windows install layouts and CI test rigs.\n #[test]\n fn locate_sibling_tui_binary_honours_env_override() {\n let dir = tempfile::TempDir::new().expect(\"tempdir\");\n let custom = dir\n .path()\n .join(format!(\"custom-tui{}\", std::env::consts::EXE_SUFFIX));\n std::fs::write(&custom, b\"\").unwrap();\n\n // Use a guard so even on test failure the env var clears.\n struct EnvGuard;\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY: tests own this env key for the duration of the\n // guard; clearing on drop matches the documented teardown\n // pattern for `std::env::set_var` in single-threaded tests.\n unsafe { std::env::remove_var(\"DEEPSEEK_TUI_BIN\") };\n }\n }\n let _g = EnvGuard;\n // SAFETY: same single-threaded scope contract as the guard above.\n unsafe { std::env::set_var(\"DEEPSEEK_TUI_BIN\", &custom) };\n\n let resolved = locate_sibling_tui_binary().expect(\"override must resolve\");\n assert_eq!(resolved, custom);\n }\n}\n"}}, "crates/tui/src/tui/ui/tests.rs": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785745877.2105546, "n": 221597, "h": "e7ea655a2543dd91", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new(None);\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_does_not_restore_slash_command_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\"user\", \"/sessions\")]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.input, \"\");\n assert!(app.queued_draft.is_none());\n assert_eq!(app.api_messages.len(), 1);\n assert!(\n app.history\n .iter()\n .any(|cell| matches!(cell, HistoryCell::User { .. }))\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_false_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n !app.composer_arrows_scroll,\n \"arrows-scroll must default to false when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix = \"a\".repeat(56); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The runtime helper should step back to the start of the char\n // and append the ellipsis without panicking.\n let truncated = crate::utils::truncate_with_ellipsis(&summary, 60, \"…\");\n assert_eq!(truncated, format!(\"{prefix}…\"));\n }\n}\n"}, "a6bd5ac08b55f10fba7600748d80ea4295d06b64": {"t": 1785756831.0885108, "n": 221673, "h": "7054711e41cb00ea", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new(None);\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_does_not_restore_slash_command_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\"user\", \"/sessions\")]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.input, \"\");\n assert!(app.queued_draft.is_none());\n assert_eq!(app.api_messages.len(), 1);\n assert!(\n app.history\n .iter()\n .any(|cell| matches!(cell, HistoryCell::User { .. }))\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix = \"a\".repeat(56); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The runtime helper should step back to the start of the char\n // and append the ellipsis without panicking.\n let truncated = crate::utils::truncate_with_ellipsis(&summary, 60, \"…\");\n assert_eq!(truncated, format!(\"{prefix}…\"));\n }\n}\n"}, "b220113a79522a57afe4b25314d12079805c213b": {"t": 1785756833.048422, "n": 221108, "h": "7a988f132e08e218", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new(None);\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix = \"a\".repeat(56); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The runtime helper should step back to the start of the char\n // and append the ellipsis without panicking.\n let truncated = crate::utils::truncate_with_ellipsis(&summary, 60, \"…\");\n assert_eq!(truncated, format!(\"{prefix}…\"));\n }\n}\n"}, "dbbc51a700c2c7ecd5bdfe0fea5cffb04d1f27d4": {"t": 1785756835.198357, "n": 221296, "h": "8f0c4b6a4760a679", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new(None);\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix = \"a\".repeat(56); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The fix: floor_char_boundary steps back to the start of the char.\n let byte_end = summary.floor_char_boundary(57);\n assert!(summary.is_char_boundary(byte_end));\n assert!(byte_end <= 57);\n // Should have stepped back to byte 56 (end of ASCII prefix).\n assert_eq!(byte_end, 56);\n\n // The slice should not panic.\n let truncated = &summary[..byte_end];\n assert_eq!(truncated, prefix);\n }\n}\n"}, "80111ffad39ca3487ca0bf5dec3b401beb9c74fa": {"t": 1785756836.9861183, "n": 221331, "h": "483fc34bfa8a6a23", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new(None);\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix: String = std::iter::repeat('a').take(56).collect(); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The fix: floor_char_boundary steps back to the start of the char.\n let byte_end = summary.floor_char_boundary(57);\n assert!(summary.is_char_boundary(byte_end));\n assert!(byte_end <= 57);\n // Should have stepped back to byte 56 (end of ASCII prefix).\n assert_eq!(byte_end, 56);\n\n // The slice should not panic.\n let truncated = &summary[..byte_end];\n assert_eq!(truncated, prefix);\n }\n}\n"}, "25ce4f59700bcac15ae0df913c9540db9b95a051": {"t": 1785756838.867283, "n": 221327, "h": "e8fc95463009eeb6", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn inline_skill_slash_popup_lists_cached_skills_in_message() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert!(entries.iter().any(|entry| entry.name == \"/search-files\"));\n assert!(entries.iter().any(|entry| entry.name == \"/my-review\"));\n assert!(entries.iter().all(|entry| entry.is_skill));\n}\n\n#[test]\nfn inline_skill_slash_popup_filters_partial_without_leaking_to_command_position() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"please use /my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_slash_menu_entries(&app, 128);\n\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].name, \"/my-review\");\n\n app.input = \"/se\".to_string();\n app.cursor_position = app.input.chars().count();\n let command_entries = visible_slash_menu_entries(&app, 128);\n assert!(\n !command_entries\n .iter()\n .any(|entry| entry.name == \"/search-files\" && entry.is_skill),\n \"command-position slash menu should not include inline skill mentions\"\n );\n}\n\n#[test]\nfn apply_slash_menu_selection_splices_inline_skill_mention() {\n let mut app = create_test_app();\n app.input = \"please use /se here\".to_string();\n app.cursor_position = \"please use /se\".chars().count();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"please use /search-files here\");\n assert_eq!(\n app.cursor_position,\n \"please use /search-files\".chars().count()\n );\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn activity_footer_hint_uses_details_for_subagent_cards() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::SubAgent(\n crate::tui::history::SubAgentCell::Delegate(\n crate::tui::widgets::agent_card::DelegateCard::new(\"agent_123\", \"general\"),\n ),\n )];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n let expected = format!(\n \"{} Activity: sub-agent · {} details\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+[`, `Alt+]`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"command shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n\n #[test]\n fn receipt_summary_truncation_does_not_panic_on_multibyte_boundary() {\n // Build a summary where byte 57 falls mid-character (em dash is 3 bytes).\n // 56 ASCII chars + em dash ensures byte 57 lands inside the em dash.\n let prefix: String = std::iter::repeat('a').take(56).collect(); // 56 ASCII bytes\n let summary = format!(\"{prefix}— rest of summary\"); // byte 56='a', 57-59='—'\n assert!(summary.len() > 60);\n // Byte 57 should be inside the em dash (3-byte UTF-8 sequence).\n assert!(!summary.is_char_boundary(57));\n\n // The fix: floor_char_boundary steps back to the start of the char.\n let byte_end = summary.floor_char_boundary(57);\n assert!(summary.is_char_boundary(byte_end));\n assert!(byte_end <= 57);\n // Should have stepped back to byte 56 (end of ASCII prefix).\n assert_eq!(byte_end, 56);\n\n // The slice should not panic.\n let truncated = &summary[..byte_end];\n assert_eq!(truncated, prefix);\n }\n}\n"}, "8a0c875d7568d31a421ce63d800f4ac3ca62aeb7": {"t": 1785756840.9410248, "n": 217107, "h": "8c569667be332ae7", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n // Short completed thinking now renders inline (v0.8.42 thinking-preview\n // change); it should be selectable/copyable as visible transcript text.\n assert!(\n selected.contains(\"copy thinking\"),\n \"short completed thinking should be visible inline: {selected:?}\"\n );\n // Short thinking that fits entirely inline doesn't need the Ctrl+O\n // affordance; only truncated or explicit-summary thinking shows it.\n assert!(\n !selected.contains(\"Ctrl+O\"),\n \"short completed thinking should not show the detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"shell job shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "b7bc8773f3735fc4d8f19bbf82d30559ec02f90d": {"t": 1785756842.7873461, "n": 216811, "h": "466e292e8c3fa830", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"shell job shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n#[test]\nfn stream_error_marks_active_turn_failed_without_waiting_for_turn_complete() {\n use crate::error_taxonomy::ErrorEnvelope;\n\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_id = Some(\"turn_decode_error\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n handle_tool_call_started(\n &mut app,\n \"tool-running\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"cargo test --workspace\"}),\n );\n assert!(app.active_cell.is_some(), \"precondition: live tool cell\");\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::classify(\"chunk decode error\".to_string(), true),\n );\n\n assert!(!app.is_loading);\n assert_eq!(app.runtime_turn_status.as_deref(), Some(\"failed\"));\n assert!(\n app.active_cell.is_none(),\n \"stream error should flush live cells so no row stays visually running\"\n );\n assert!(\n app.history.iter().any(|cell| {\n matches!(\n cell,\n crate::tui::history::HistoryCell::Error { message, .. }\n if message.contains(\"chunk decode error\")\n )\n }),\n \"stream decode error should remain visible in transcript\"\n );\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "41590edfd834f46b69823ef38671b4a87b23c26f": {"t": 1785756844.560396, "n": 215548, "h": "228f964d7fcbc2b8", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"shell-wait\",\n \"exec_shell_wait\",\n &serde_json::json!({\"task_id\": \"shell_33a08c3c\"}),\n );\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"shell job shell_33a08c3c\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"shell_33a08c3c\"))\n );\n assert!(\n !exec.command.contains(\"\")\n && !exec\n .interaction\n .as_deref()\n .unwrap_or_default()\n .contains(\"\")\n );\n\n let result = Ok(crate::tools::spec::ToolResult::success(\n \"Background task running (no new output).\",\n )\n .with_metadata(serde_json::json!({\n \"status\": \"Running\",\n \"duration_ms\": 178_000_u64,\n \"task_id\": \"shell_33a08c3c\",\n \"command\": \"cargo test --workspace --all-features\",\n })));\n handle_tool_call_complete(&mut app, \"shell-wait\", \"exec_shell_wait\", &result);\n\n let exec = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .iter()\n .find_map(|cell| match cell {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .expect(\"exec cell\");\n assert_eq!(exec.command, \"cargo test --workspace --all-features\");\n assert!(\n exec.interaction\n .as_deref()\n .is_some_and(|text| text.contains(\"cargo test --workspace\"))\n );\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "5fa24733e9a1d11315506309b8edb0fdf2b94f04": {"t": 1785756846.452598, "n": 213634, "h": "f7690c7652c72577", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "0f03c7213034b180efbda2fa0d8527c8995d7661": {"t": 1785756848.7028012, "n": 213637, "h": "d1f1d49a957b5938", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "9e822a357692a5ea2d1288c2cadc5c56a47571af": {"t": 1785756850.6784055, "n": 213632, "h": "3c4f0ac0cca54841", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n\n// === Bug #1913: Work sidebar should hide stale completed tasks ============\n//\n// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every\n// durable task the user has ever run. Without filtering, completed tasks\n// from prior sessions persist indefinitely. The projection helper keeps\n// active tasks, keeps tasks that finished during this session, keeps tasks\n// that finished within the last `recent_ttl`, and drops everything older.\n\nmod work_sidebar_projection_tests {\n use super::*;\n use crate::task_manager::{TaskStatus, TaskSummary};\n use chrono::{Duration, TimeZone, Utc};\n\n fn sample_task(\n id: &str,\n status: TaskStatus,\n ended_at: Option>,\n ) -> TaskSummary {\n TaskSummary {\n id: id.to_string(),\n status,\n prompt_summary: format!(\"task {id}\"),\n model: \"deepseek-v4-flash\".to_string(),\n mode: \"agent\".to_string(),\n created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(),\n started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()),\n ended_at,\n duration_ms: ended_at.map(|_| 1_234),\n error: None,\n thread_id: None,\n turn_id: None,\n }\n }\n\n #[test]\n fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() {\n // Pretend the TUI session started on 2026-05-23T10:00:00Z. \"Now\"\n // is one minute into the session.\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::minutes(1);\n let recent_ttl = Duration::hours(2);\n\n let active_running = sample_task(\"active_run\", TaskStatus::Running, None);\n let active_queued = sample_task(\"active_q\", TaskStatus::Queued, None);\n\n // Completed during the current session — must show.\n let just_finished = sample_task(\n \"just_done\",\n TaskStatus::Completed,\n Some(session_started_at + Duration::seconds(30)),\n );\n\n // Completed shortly before the session started, inside the\n // recent-TTL window — must show.\n let recently_finished_before_session = sample_task(\n \"recent_done\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::minutes(15)),\n );\n\n // Stale completed from 6 days ago (the exact scenario in #1913) —\n // must be hidden.\n let stale_completed = sample_task(\n \"stale_done\",\n TaskStatus::Completed,\n Some(session_started_at - Duration::days(6)),\n );\n let stale_canceled = sample_task(\n \"stale_cancel\",\n TaskStatus::Canceled,\n Some(session_started_at - Duration::days(7)),\n );\n let stale_failed = sample_task(\n \"stale_fail\",\n TaskStatus::Failed,\n Some(session_started_at - Duration::days(3)),\n );\n\n // A terminal task without `ended_at` shouldn't sneak through.\n let terminal_no_timestamp = sample_task(\"ghost\", TaskStatus::Completed, None);\n\n let tasks = vec![\n active_running.clone(),\n active_queued.clone(),\n just_finished.clone(),\n recently_finished_before_session.clone(),\n stale_completed.clone(),\n stale_canceled.clone(),\n stale_failed.clone(),\n terminal_no_timestamp.clone(),\n ];\n\n let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl);\n let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect();\n\n assert!(\n kept_ids.contains(&\"active_run\"),\n \"active running task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"active_q\"),\n \"active queued task must always show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"just_done\"),\n \"task completed during the current session must show: {kept_ids:?}\"\n );\n assert!(\n kept_ids.contains(&\"recent_done\"),\n \"task completed within the recent TTL before session start must show: \\\n {kept_ids:?}\"\n );\n\n assert!(\n !kept_ids.contains(&\"stale_done\"),\n \"completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_cancel\"),\n \"canceled task from 7 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"stale_fail\"),\n \"failed task from 3 days ago must be hidden: {kept_ids:?}\"\n );\n assert!(\n !kept_ids.contains(&\"ghost\"),\n \"terminal task missing ended_at must be hidden: {kept_ids:?}\"\n );\n }\n\n #[test]\n fn work_sidebar_keeps_tasks_completed_at_session_boundary() {\n // Edge case: a task that finished at exactly the same instant the\n // session started should still be visible (>= comparison).\n let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap();\n let now = session_started_at + Duration::seconds(1);\n let recent_ttl = Duration::hours(2);\n\n let at_boundary = sample_task(\"boundary\", TaskStatus::Completed, Some(session_started_at));\n\n let kept =\n select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl);\n assert_eq!(kept.len(), 1);\n assert_eq!(kept[0].id, \"boundary\");\n }\n}\n"}, "6356a810c51c3fa54a2eed3a813bfcfe48f202a2": {"t": 1785756852.5231237, "n": 207996, "h": "bc78db9e42e4a4ae", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\n#[cfg(any(unix, windows))]\nfn external_url_launcher_does_not_wait_for_browser_process() {\n let command = slow_external_url_command();\n let start = Instant::now();\n\n spawn_external_url_command(command).expect(\"spawn external URL command\");\n\n assert!(\n start.elapsed() < Duration::from_millis(750),\n \"opening a feedback URL must not wait for the browser command to exit\"\n );\n}\n\n#[cfg(unix)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"sh\");\n command.args([\"-c\", \"sleep 1\"]);\n command\n}\n\n#[cfg(windows)]\nfn slow_external_url_command() -> Command {\n let mut command = Command::new(\"cmd\");\n command.args([\"/C\", \"ping -n 2 127.0.0.1 >NUL\"]);\n command\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "903e4537f4a5e915b01a75e0e189b256000c73d5": {"t": 1785756854.56332, "n": 207265, "h": "eb9744c6f1f3773f", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute codewhale run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=codewhale Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/codewhale\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"codewhale: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"codewhale:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"codewhale: sub-agent agent_live complete\"));\n assert!(msg.contains(\"codewhale: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "3c95eadc22d5589e7fff013410bfb06efeffa5d1": {"t": 1785756856.6523314, "n": 207260, "h": "af7e313b44f3c36a", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let settings = crate::settings::Settings {\n default_model: Some(\"deepseek-v4-pro\".to_string()),\n reasoning_effort: Some(\"high\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let config = Config {\n reasoning_effort: Some(\"max\".to_string()),\n ..Default::default()\n };\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "396b0e822b89fed272269cd854f0887020888495": {"t": 1785756858.8498545, "n": 207232, "h": "602d8aae6523f411", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn app_new_restores_saved_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut settings = crate::settings::Settings::default();\n settings.default_model = Some(\"deepseek-v4-pro\".to_string());\n settings.reasoning_effort = Some(\"high\".to_string());\n settings.save().expect(\"save settings\");\n\n let options = TuiOptions {\n model: \"auto\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut config = Config::default();\n config.reasoning_effort = Some(\"max\".to_string());\n\n let app = App::new(options, &config);\n\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-pro\");\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[tokio::test]\nasync fn model_picker_persists_model_and_reasoning_effort() {\n let _guard = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n app.reasoning_effort = ReasoningEffort::Auto;\n let engine = mock_engine_handle();\n\n apply_model_picker_choice(\n &mut app,\n &engine.handle,\n \"deepseek-v4-pro\".to_string(),\n ReasoningEffort::High,\n \"auto\".to_string(),\n ReasoningEffort::Auto,\n )\n .await;\n\n let settings = crate::settings::Settings::load().expect(\"load settings\");\n assert_eq!(settings.default_model.as_deref(), Some(\"deepseek-v4-pro\"));\n assert_eq!(\n settings\n .provider_models\n .as_ref()\n .and_then(|models| models.get(\"deepseek\"))\n .map(String::as_str),\n Some(\"deepseek-v4-pro\")\n );\n assert_eq!(settings.reasoning_effort.as_deref(), Some(\"high\"));\n assert!(!app.auto_model);\n assert_eq!(app.reasoning_effort, ReasoningEffort::High);\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "26c66079bab79194f0675d1b28bf3f4be9db1291": {"t": 1785756861.036254, "n": 204879, "h": "e8ec09b5149c7c45", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n parent_session_id: None,\n forked_from_message_count: None,\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn existing_session_snapshot_updates_model_selection() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut existing = saved_session_with_messages(vec![text_message(\"user\", \"hello\")]);\n existing.metadata.model = \"auto\".to_string();\n manager\n .save_session(&existing)\n .expect(\"save existing session\");\n\n let mut app = create_test_app();\n app.current_session_id = Some(existing.metadata.id.clone());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n app.set_model_selection(\"deepseek-v4-flash\".to_string());\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, existing.metadata.id);\n assert_eq!(snapshot.metadata.model, \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_concrete_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"auto\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"deepseek-v4-flash\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(!app.auto_model);\n assert_eq!(app.model, \"deepseek-v4-flash\");\n assert_eq!(app.model_selection_for_persistence(), \"deepseek-v4-flash\");\n}\n\n#[test]\nfn apply_loaded_session_restores_auto_model_mode() {\n let mut app = create_test_app();\n app.set_model_selection(\"deepseek-v4-pro\".to_string());\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.metadata.model = \"auto\".to_string();\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert!(app.auto_model);\n assert_eq!(app.model, \"auto\");\n assert_eq!(app.model_selection_for_persistence(), \"auto\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "4144fc6a2b03a7b7302bda5ab12fc228561ce8f6": {"t": 1785756897.3905256, "n": 202713, "h": "500593e8d7c77bc1", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn steer_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\n \"adjust the active turn\\nthen continue\".to_string(),\n None,\n );\n\n steer_user_message(&mut app, &engine.handle, queued)\n .await\n .expect(\"steer user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n assert_eq!(\n engine.rx_steer.recv().await.as_deref(),\n Some(\"adjust the active turn\\nthen continue\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "710505db76d22c2ff01a7382e36336e7ac00d479": {"t": 1785756901.1188214, "n": 202030, "h": "91ae3f972aa3ba6c", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn local_cancel_marks_late_stream_events_for_suppression() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.streaming_state.start_text(0, None);\n\n mark_active_turn_cancelled_locally(&mut app);\n\n assert!(!app.is_loading);\n assert!(app.suppress_stream_events_until_turn_complete);\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::ThinkingDelta {\n index: 0,\n content: \"late thinking\".to_string(),\n }\n ));\n assert!(suppress_engine_event_after_local_cancel(\n &EngineEvent::SessionUpdated {\n session_id: \"session\".to_string(),\n messages: Vec::new(),\n system_prompt: None,\n model: \"deepseek-v4-flash\".to_string(),\n workspace: PathBuf::from(\".\"),\n }\n ));\n assert!(ignore_stale_stream_event_while_idle(\n &EngineEvent::MessageDelta {\n index: 0,\n content: \"late text\".to_string(),\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::TurnComplete {\n usage: Usage::default(),\n status: crate::core::events::TurnOutcomeStatus::Interrupted,\n error: None,\n }\n ));\n assert!(!suppress_engine_event_after_local_cancel(\n &EngineEvent::Status {\n message: \"Request cancelled\".to_string(),\n }\n ));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_records_prompt_for_cancel_restore() {\n let mut app = create_test_app();\n let config = Config::default();\n let mut engine = crate::core::engine::mock_engine_handle();\n let queued = crate::tui::app::QueuedMessage::new(\"fix this typo\\nthen retry\".to_string(), None);\n\n dispatch_user_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"dispatch user message\");\n\n assert_eq!(\n app.last_submitted_prompt.as_deref(),\n Some(\"fix this typo\\nthen retry\")\n );\n match engine.rx_op.recv().await.expect(\"send message op\") {\n crate::core::ops::Op::SendMessage { content, .. } => {\n assert_eq!(content, \"fix this typo\\nthen retry\");\n }\n other => panic!(\"expected SendMessage, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "ba8b4b7adf4aaa351edb2dd0349ffe44fd401b4c": {"t": 1785756930.0672, "n": 199618, "h": "9d4a144b53ca98aa", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_stays_at_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n // Cursor sitting on the first '\\n'.\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n // Stays at end of current line.\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "f77a07e20742beefe8d12afe3424c94d39946e2b": {"t": 1785756931.8037958, "n": 199555, "h": "5abbd73b075dfb3b", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn home_jumps_to_line_start_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = app.input.chars().count();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\nline two\\n\".len());\n}\n\n#[test]\nfn home_from_middle_of_line_jumps_to_line_start() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn home_on_singleline_jumps_to_zero() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 6;\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, 0);\n}\n\n#[test]\nfn end_jumps_to_line_end_multiline() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\".len());\n}\n\n#[test]\nfn end_from_middle_of_line_jumps_to_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\nli\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn end_on_singleline_jumps_to_absolute_end() {\n let mut app = create_test_app();\n app.input = \"hello world\".to_string();\n app.cursor_position = 0;\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn home_at_line_start_stays_put() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = \"line one\\n\".len();\n app.move_cursor_line_start();\n assert_eq!(app.cursor_position, \"line one\\n\".len());\n}\n\n#[test]\nfn end_at_newline_skips_to_next_line_end() {\n let mut app = create_test_app();\n app.input = \"line one\\nline two\\nline three\".to_string();\n app.cursor_position = \"line one\".len();\n app.move_cursor_line_end();\n assert_eq!(app.cursor_position, \"line one\\nline two\".len());\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "373fbd95a0316c3e9b7222560c7bdba54bab4767": {"t": 1785756933.8521852, "n": 197339, "h": "f4dd900d12a2eb0f", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_also_scrolls() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // #1677: terminals that convert mouse-wheel to arrow keys should scroll\n // the transcript without mutating a draft the user is editing.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert_eq!(app.input, \"hello\");\n}\n\n#[test]\nfn composer_arrow_up_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n}\n\n#[test]\nfn composer_arrow_down_moves_within_multiline_input() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"next prompt\".to_string());\n app.history_index = Some(app.input_history.len() - 1);\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position >= \"line one\\n\".chars().count());\n}\n\n#[test]\nfn composer_arrows_scroll_multiline_input_navigates_lines() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"line one\\nline two\");\n assert!(app.cursor_position < app.input.chars().count());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn composer_arrow_up_at_first_line_falls_back_to_history_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = false;\n app.input = \"line one\\nline two\".to_string();\n app.cursor_position = 0;\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "5401eaae0869a0676099afcc99c003664d52f049": {"t": 1785756935.5114357, "n": 194810, "h": "2d72960e2ae30731", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\n#[test]\nfn session_denied_cache_matches_only_approval_key() {\n let mut app = create_test_app();\n app.approval_session_denied.insert(\"edit_file\".to_string());\n\n assert!(\n !is_session_denied_for_key(&app, \"file:edit_file:fresh\"),\n \"a legacy tool-name entry must not deny a later fresh call\"\n );\n\n app.approval_session_denied\n .insert(\"file:edit_file:retry\".to_string());\n assert!(is_session_denied_for_key(&app, \"file:edit_file:retry\"));\n}\n\n#[test]\nfn session_approved_cache_keeps_tool_name_session_grants() {\n let mut app = create_test_app();\n app.approval_session_approved\n .insert(\"edit_file\".to_string());\n\n assert!(\n is_session_approved_for_tool(&app, \"edit_file\", \"file:edit_file:fresh\"),\n \"approve-for-session should still cover future calls of the same tool\"\n );\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "7c8c71eb03d8fa0ca9b30af0f0da306a563a4c63": {"t": 1785756937.4384522, "n": 193966, "h": "a5e526621a9d4830", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_follow_platform_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert_eq!(\n app.composer_arrows_scroll,\n cfg!(windows),\n \"arrows-scroll should default to true on Windows and false on other platforms when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "f8a4dee1735012b5dcb3c5e91d448175e12ace6e": {"t": 1785756939.1609416, "n": 193890, "h": "59d6a38fd6791537", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\nstruct SettingsHomeGuard {\n _tmp: TempDir,\n previous_home: Option,\n previous_userprofile: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl SettingsHomeGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"settings tempdir\");\n let previous_home = std::env::var_os(\"HOME\");\n let previous_userprofile = std::env::var_os(\"USERPROFILE\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n Self {\n _tmp: tmp,\n previous_home,\n previous_userprofile,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for SettingsHomeGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n match self.previous_home.take() {\n Some(previous) => std::env::set_var(\"HOME\", previous),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.previous_userprofile.take() {\n Some(previous) => std::env::set_var(\"USERPROFILE\", previous),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[test]\nfn saved_default_provider_syncs_back_to_runtime_config() {\n let _home = SettingsHomeGuard::new();\n let settings = crate::settings::Settings {\n default_provider: Some(\"ollama\".to_string()),\n ..Default::default()\n };\n settings.save().expect(\"save settings\");\n\n let mut config = Config::default();\n assert_eq!(config.api_provider(), ApiProvider::Deepseek);\n\n let app = App::new(create_test_options(), &config);\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n\n sync_config_provider_from_app(&mut config, &app);\n\n assert_eq!(config.api_provider(), ApiProvider::Ollama);\n}\n\n#[test]\nfn provider_picker_reselecting_active_provider_preserves_current_model() {\n let mut app = create_test_app();\n app.api_provider = ApiProvider::Ollama;\n app.model = \"deepseek-coder-v2:16b\".to_string();\n\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Ollama).as_deref(),\n Some(\"deepseek-coder-v2:16b\")\n );\n assert_eq!(\n provider_picker_model_override(&app, ApiProvider::Deepseek),\n None\n );\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_false_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n !app.composer_arrows_scroll,\n \"arrows-scroll must default to false when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "4c32a316bea28bd5b63f39b0b0e8d7040af92ac5": {"t": 1785756943.014242, "n": 191427, "h": "b03026c5fee457b1", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_keeps_prefix_stability_opt_in() {\n let items = crate::config::StatusItem::default_footer();\n\n assert!(\n !items.contains(&crate::config::StatusItem::PrefixStability),\n \"prefix stability is a diagnostic chip and should not crowd the default footer\"\n );\n assert!(\n items.contains(&crate::config::StatusItem::Cache),\n \"default footer should still include provider-reported cache hit rate\"\n );\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"cache prefix 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"cache prefix 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_false_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n !app.composer_arrows_scroll,\n \"arrows-scroll must default to false when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "13e7957621448792beda06ec8615e33cb374adce": {"t": 1785756945.4669585, "n": 191441, "h": "211cb5ec05a398f8", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>0u\"),\n \"push_keyboard_enhancement_flags must write kitty probe (\\\\x1b[>0u) on Windows (#1599); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_includes_prefix_stability_before_cache() {\n let items = crate::config::StatusItem::default_footer();\n let prefix = items\n .iter()\n .position(|item| *item == crate::config::StatusItem::PrefixStability)\n .expect(\"default footer includes prefix stability\");\n let cache = items\n .iter()\n .position(|item| *item == crate::config::StatusItem::Cache)\n .expect(\"default footer includes cache\");\n\n assert!(prefix < cache);\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"P 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"P 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_false_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n !app.composer_arrows_scroll,\n \"arrows-scroll must default to false when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}, "11c655b32ac457906c3b1906bafc603682ce632a": {"t": 1785756948.3292818, "n": 191440, "h": "08f63fa7183b729e", "c": "use super::*;\nuse crate::config::{ApiProvider, Config};\nuse crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};\nuse crate::core::engine::mock_engine_handle;\nuse crate::tui::active_cell::ActiveCell;\nuse crate::tui::app::ToolDetailRecord;\nuse crate::tui::file_mention::{\n apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,\n try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,\n};\nuse crate::tui::footer_ui::{\n active_tool_status_label, footer_auxiliary_spans, footer_cache_spans, footer_coherence_spans,\n footer_state_label, footer_status_line_spans, format_context_budget,\n format_token_count_compact, friendly_subagent_progress, render_footer_from,\n};\nuse crate::tui::history::{\n ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,\n};\nuse crate::tui::views::{ModalView, ViewAction};\nuse crate::working_set::Workspace;\nuse crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};\nuse ratatui::text::Span;\nuse std::collections::HashSet;\nuse std::ffi::OsString;\nuse std::path::PathBuf;\nuse std::process::Command;\nuse std::sync::MutexGuard;\nuse std::time::{Duration, Instant};\nuse unicode_width::UnicodeWidthStr;\n\nuse crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};\nuse tempfile::TempDir;\n\nstruct ConfigPathEnvGuard {\n _tmp: TempDir,\n previous: Option,\n _lock: MutexGuard<'static, ()>,\n}\n\nimpl ConfigPathEnvGuard {\n fn new() -> Self {\n let lock = crate::test_support::lock_test_env();\n let tmp = TempDir::new().expect(\"config tempdir\");\n let config_path = tmp.path().join(\".deepseek\").join(\"config.toml\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\")).expect(\"config dir\");\n let previous = std::env::var_os(\"DEEPSEEK_CONFIG_PATH\");\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", &config_path);\n }\n Self {\n _tmp: tmp,\n previous,\n _lock: lock,\n }\n }\n}\n\nimpl Drop for ConfigPathEnvGuard {\n fn drop(&mut self) {\n // Safety: test-only environment mutation guarded by a global mutex.\n unsafe {\n if let Some(previous) = self.previous.take() {\n std::env::set_var(\"DEEPSEEK_CONFIG_PATH\", previous);\n } else {\n std::env::remove_var(\"DEEPSEEK_CONFIG_PATH\");\n }\n }\n }\n}\n\n#[test]\nfn resume_hint_uses_canonical_resume_command() {\n assert_eq!(\n resume_hint_text(),\n \"To continue this session, execute deepseek run --continue\"\n );\n assert!(should_show_resume_hint(Some(\n \"019dd9d6-4f44-7c83-9863-59674a12b827\"\n )));\n}\n\n#[test]\nfn resume_hint_omits_missing_session_id() {\n assert!(!should_show_resume_hint(None));\n assert!(!should_show_resume_hint(Some(\" \")));\n}\n\n#[test]\nfn plain_mcp_show_refreshes_discovery_counts() {\n use crate::tui::app::McpUiAction;\n\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Show));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Validate));\n assert!(mcp_ui_action_refreshes_discovery(&McpUiAction::Reload));\n assert!(!mcp_ui_action_refreshes_discovery(&McpUiAction::Init {\n force: false,\n }));\n}\n\n#[test]\nfn focus_gained_forces_terminal_viewport_recapture() {\n assert!(terminal_event_needs_viewport_recapture(&Event::FocusGained));\n assert!(!terminal_event_needs_viewport_recapture(&Event::FocusLost));\n}\n\n// ANSI byte sequences are only written on platforms where crossterm uses the\n// ANSI execution path. On Windows the same logical commands route through the\n// WinAPI console backend and never reach the writer, so byte-level assertions\n// here only make sense on non-Windows targets.\n#[cfg(not(windows))]\n#[test]\nfn recover_terminal_modes_emits_expected_csi_sequences_with_gating() {\n let mut all_on: Vec = Vec::new();\n let mut all_off: Vec = Vec::new();\n recover_terminal_modes(&mut all_on, true, true);\n recover_terminal_modes(&mut all_off, false, false);\n let on = String::from_utf8_lossy(&all_on);\n let off = String::from_utf8_lossy(&all_off);\n\n assert!(\n on.contains(\"\\x1b[?1004h\") && off.contains(\"\\x1b[?1004h\"),\n \"EnableFocusChange must be re-armed regardless of gating\"\n );\n assert!(\n on.contains(\"\\x1b[>1u\") && off.contains(\"\\x1b[>1u\"),\n \"Kitty keyboard disambiguation flag must be re-pushed regardless of gating\"\n );\n\n assert!(\n on.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture missing when use_mouse_capture=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?1000h\"),\n \"EnableMouseCapture must be gated by use_mouse_capture\"\n );\n\n assert!(\n on.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste missing when use_bracketed_paste=true\"\n );\n assert!(\n !off.contains(\"\\x1b[?2004h\"),\n \"EnableBracketedPaste must be gated by use_bracketed_paste\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn recover_terminal_modes_runs_without_panic_on_windows() {\n let mut buf: Vec = Vec::new();\n recover_terminal_modes(&mut buf, true, true);\n recover_terminal_modes(&mut buf, false, false);\n}\n\n// On Windows crossterm's PushKeyboardEnhancementFlags never writes bytes\n// (is_ansi_code_supported() == false), so the fix writes the escape\n// directly. Verify the direct path emits the expected Kitty keyboard\n// protocol sequence so the Windows fix for #1359 is not accidentally reverted.\n#[cfg(windows)]\n#[test]\nfn push_keyboard_flags_writes_kitty_push_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n push_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[>1u\"),\n \"push_keyboard_enhancement_flags must write kitty push (\\\\x1b[>1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[cfg(windows)]\n#[test]\nfn pop_keyboard_flags_writes_kitty_pop_sequence_on_windows() {\n let mut buf: Vec = Vec::new();\n pop_keyboard_enhancement_flags(&mut buf);\n let seq = String::from_utf8_lossy(&buf);\n assert!(\n seq.contains(\"\\x1b[<1u\"),\n \"pop_keyboard_enhancement_flags must write kitty pop (\\\\x1b[<1u) on Windows (#1359); got: {seq:?}\"\n );\n}\n\n#[test]\nfn terminal_origin_reset_resets_scroll_region_origin_without_destructive_clear() {\n assert!(\n TERMINAL_ORIGIN_RESET.starts_with(b\"\\x1b[r\\x1b[?6l\"),\n \"must reset scroll margins and origin mode before repaint\"\n );\n assert!(\n TERMINAL_ORIGIN_RESET.ends_with(b\"\\x1b[H\"),\n \"must home the cursor at the end of the reset sequence\"\n );\n // Cross-terminal flicker regression (#1119, #1352, #1356, #1363, #1366,\n // #1260, #1295): emitting CSI 2J/3J here in addition to the\n // immediately-following ratatui `terminal.clear()` produced a visible\n // blank-then-repaint flicker on Ghostty / VSCode terminal / Win10 conhost\n // every TurnComplete. The cleared back-buffer plus a single ratatui clear\n // is sufficient on the alt-screen.\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[2J\".len())\n .any(|sequence| sequence == b\"\\x1b[2J\"),\n \"must not emit destructive CSI 2J — causes visible flicker\"\n );\n assert!(\n !TERMINAL_ORIGIN_RESET\n .windows(b\"\\x1b[3J\".len())\n .any(|sequence| sequence == b\"\\x1b[3J\"),\n \"must not emit destructive CSI 3J — causes visible flicker\"\n );\n}\n\n#[test]\nfn composer_newline_shortcuts_do_not_steal_ctrl_enter() {\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Char('j'),\n KeyModifiers::CONTROL,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::ALT,\n )));\n assert!(is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::SHIFT,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::NONE,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL,\n )));\n assert!(!is_composer_newline_key(KeyEvent::new(\n KeyCode::Enter,\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n}\n\n#[test]\nfn word_cursor_modifier_accepts_control_and_alt() {\n assert!(is_word_cursor_modifier(KeyModifiers::CONTROL));\n assert!(is_word_cursor_modifier(KeyModifiers::ALT));\n assert!(is_word_cursor_modifier(\n KeyModifiers::CONTROL | KeyModifiers::SHIFT\n ));\n assert!(!is_word_cursor_modifier(KeyModifiers::NONE));\n assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT));\n}\n\n#[test]\nfn selection_point_from_position_ignores_top_padding() {\n let area = Rect {\n x: 10,\n y: 20,\n width: 30,\n height: 5,\n };\n\n // Content is bottom-aligned: 2 transcript lines in a 5-row viewport.\n let padding_top = 3;\n let transcript_top = 0;\n let transcript_total = 2;\n\n // Click in padding area -> no selection\n assert!(\n selection_point_from_position(\n area,\n area.x + 1,\n area.y,\n transcript_top,\n transcript_total,\n padding_top,\n )\n .is_none()\n );\n\n // First transcript line is at row `padding_top`\n let p0 = selection_point_from_position(\n area,\n area.x + 2,\n area.y + u16::try_from(padding_top).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p0.line_index, 0);\n assert_eq!(p0.column, 2);\n\n // Second transcript line is one row below\n let p1 = selection_point_from_position(\n area,\n area.x,\n area.y + u16::try_from(padding_top + 1).expect(\"padding should fit\"),\n transcript_top,\n transcript_total,\n padding_top,\n )\n .expect(\"point\");\n assert_eq!(p1.line_index, 1);\n assert_eq!(p1.column, 0);\n}\n\n#[test]\nfn selection_to_text_handles_multiline_and_reversed_endpoints() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\\ngamma delta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 1,\n column: 5,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 6,\n });\n\n assert_eq!(selection_to_text(&app).as_deref(), Some(\"a beta\\ngam\"));\n}\n\n#[test]\nfn selection_to_text_copies_rendered_transcript_block() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::System {\n content: \"copy system\".to_string(),\n },\n HistoryCell::User {\n content: \"copy user\".to_string(),\n },\n HistoryCell::Thinking {\n content: \"copy thinking\".to_string(),\n streaming: false,\n duration_secs: Some(1.0),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"cargo check\".to_string()),\n output: Some(\"tool output line\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"copy assistant\".to_string(),\n streaming: false,\n },\n ];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1),\n column: 80,\n });\n\n let selected = selection_to_text(&app).expect(\"selection text\");\n assert!(selected.contains(\"Note copy system\"), \"{selected:?}\");\n assert!(selected.contains(\"copy user\"), \"{selected:?}\");\n assert!(\n !selected.contains(\"copy thinking\"),\n \"raw completed thinking should stay out of live selection text: {selected:?}\"\n );\n assert!(\n selected.contains(\"Ctrl+O\"),\n \"selection should keep the reasoning detail affordance: {selected:?}\"\n );\n assert!(selected.contains(\"tool output line\"), \"{selected:?}\");\n assert!(selected.contains(\"copy assistant\"), \"{selected:?}\");\n // #1163: tool-card middle lines are rendered with a `│ ` left rail\n // glyph, but that decoration must not leak into copied text. Assert\n // no isolated rail glyph survives at the start of any line.\n for (idx, line) in selected.lines().enumerate() {\n assert!(\n !line.starts_with(\"\\u{2502} \"),\n \"line {idx} retained tool-card rail prefix: {line:?}\"\n );\n }\n}\n\n#[test]\nfn selection_has_content_rejects_zero_width_selection() {\n let mut app = create_test_app();\n let point = TranscriptSelectionPoint {\n line_index: 0,\n column: 3,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(!selection_has_content(&app));\n}\n\n#[test]\nfn mouse_selection_autocopies_on_release_without_ctrl_c() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.last_transcript_padding_top = 0;\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 8,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert_eq!(app.status_message.as_deref(), Some(\"Selection copied\"));\n assert!(\n app.clipboard\n .last_written_text()\n .is_some_and(|text| text.contains(\"alpha\")),\n \"selection should be written to clipboard\"\n );\n}\n\n#[test]\nfn loading_mouse_filter_keeps_active_drags() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n let moved = MouseEvent {\n kind: MouseEventKind::Moved,\n column: 3,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n let drag = MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 5,\n row: 2,\n modifiers: KeyModifiers::NONE,\n };\n\n assert!(should_drop_loading_mouse_motion(&app, moved));\n assert!(should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n\n app.viewport.transcript_selection.dragging = false;\n app.viewport.transcript_scrollbar_dragging = true;\n assert!(!should_drop_loading_mouse_motion(&app, drag));\n}\n\n#[test]\nfn jump_to_latest_button_click_scrolls_to_tail() {\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.viewport.jump_to_latest_button_area = Some(Rect {\n x: 10,\n y: 5,\n width: 3,\n height: 3,\n });\n app.user_scrolled_during_stream = true;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 11,\n row: 6,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_scroll.is_at_tail());\n assert!(app.viewport.jump_to_latest_button_area.is_none());\n assert!(!app.user_scrolled_during_stream);\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n/// Clicking the transcript scrollbar gutter starts a scrollbar drag (not\n/// text selection) so the visible thumb remains interactive for users who\n/// prefer mouse-based navigation.\n#[test]\nfn transcript_scrollbar_gutter_starts_scrollbar_drag() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n // Left-down on the scrollbar gutter (column == right edge) starts a\n // scrollbar drag, not a transcript selection.\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 21,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(\n app.viewport.transcript_scrollbar_dragging,\n \"gutter click should start scrollbar drag\"\n );\n assert!(\n !app.viewport.transcript_selection.dragging,\n \"gutter click should NOT start text selection\"\n );\n\n // Drag moves the viewport (no assertion on exact scroll position — the\n // mapping depends on area geometry).\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n assert!(app.viewport.transcript_scrollbar_dragging);\n\n // Left-up ends the scrollbar drag.\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 21,\n row: 14,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(!app.viewport.transcript_scrollbar_dragging);\n}\n\n#[test]\nfn left_down_inside_transcript_starts_selection() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.last_transcript_area = Some(Rect {\n x: 2,\n y: 5,\n width: 20,\n height: 10,\n });\n app.viewport.last_transcript_visible = 10;\n app.viewport.last_transcript_total = 110;\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Left),\n column: 3,\n row: 5,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert!(app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn drag_below_viewport_arms_autoscroll_down() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 4,\n row: 12, // below area.y + area.height (= 8)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, 1);\n assert_eq!(state.column, 4);\n}\n\n#[test]\nfn drag_above_viewport_arms_autoscroll_up() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 5,\n y: 4,\n width: 40,\n height: 6,\n });\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 5,\n column: 0,\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 50, // outside horizontally too — clamped to area.x + width - 1\n row: 1, // above area.y (= 4)\n modifiers: KeyModifiers::NONE,\n },\n );\n\n let state = app.viewport.selection_autoscroll.expect(\"autoscroll armed\");\n assert_eq!(state.direction, -1);\n assert_eq!(state.column, 5 + 40 - 1);\n}\n\n#[test]\nfn drag_back_inside_disarms_autoscroll() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 4,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Drag(MouseButton::Left),\n column: 6,\n row: 0, // inside area\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head present\");\n assert_eq!(head.column, 6);\n}\n\n#[test]\nfn mouse_up_clears_selection_autoscroll() {\n let mut app = create_test_app();\n app.viewport.transcript_selection.dragging = true;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: -1,\n column: 0,\n next_tick: Instant::now(),\n });\n\n handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Up(MouseButton::Left),\n column: 0,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert!(!app.viewport.transcript_selection.dragging);\n}\n\n#[test]\nfn tick_selection_autoscroll_advances_pending_scroll_when_due() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_total = 200;\n app.viewport.transcript_selection.dragging = true;\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n let earlier = Instant::now() - Duration::from_millis(100);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 10,\n next_tick: earlier,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 1);\n assert!(app.user_scrolled_during_stream);\n let next_tick = app\n .viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick;\n assert!(next_tick > earlier);\n let head = app\n .viewport\n .transcript_selection\n .head\n .expect(\"head extended\");\n // Edge row for direction = +1 is the bottom of area (height - 1 = 7),\n // so head.line_index should equal last_transcript_top + 7.\n assert_eq!(head.line_index, 7);\n assert_eq!(head.column, 10);\n}\n\n#[test]\nfn tick_selection_autoscroll_respects_cadence() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = true;\n let future = Instant::now() + Duration::from_secs(60);\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: future,\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(\n app.viewport\n .selection_autoscroll\n .expect(\"still armed\")\n .next_tick,\n future,\n \"next_tick must not advance before its deadline\"\n );\n}\n\n#[test]\nfn tick_selection_autoscroll_clears_when_drag_ended() {\n let mut app = create_test_app();\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.transcript_selection.dragging = false;\n app.viewport.selection_autoscroll = Some(SelectionAutoscroll {\n direction: 1,\n column: 0,\n next_tick: Instant::now() - Duration::from_millis(100),\n });\n\n tick_selection_autoscroll(&mut app);\n\n assert!(app.viewport.selection_autoscroll.is_none());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n}\n\n#[test]\nfn right_click_opens_context_menu() {\n let mut app = create_test_app();\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));\n}\n\n#[test]\nfn right_click_menu_includes_selection_and_clicked_cell_actions() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Assistant {\n content: \"alpha beta\".to_string(),\n streaming: false,\n }];\n app.resync_history_revisions();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &app.history_revisions,\n 80,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_area = Some(Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 8,\n });\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_total = app.viewport.transcript_cache.total_lines();\n app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 0,\n });\n app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint {\n line_index: 0,\n column: 5,\n });\n\n let entries = build_context_menu_entries(\n &app,\n MouseEvent {\n kind: MouseEventKind::Down(MouseButton::Right),\n column: 2,\n row: 0,\n modifiers: KeyModifiers::NONE,\n },\n );\n let labels = entries\n .iter()\n .map(|entry| entry.label.as_str())\n .collect::>();\n\n assert!(labels.contains(&\"Copy selection\"));\n assert!(labels.contains(&\"Open selection\"));\n assert!(labels.contains(&\"Open details\"));\n assert!(labels.contains(&\"Paste\"));\n}\n\n#[test]\nfn mouse_events_do_not_mutate_transcript_behind_modal() {\n let mut app = create_test_app();\n app.view_stack.push(HelpView::new_for_locale(app.ui_locale));\n\n let events = handle_mouse_event(\n &mut app,\n MouseEvent {\n kind: MouseEventKind::ScrollUp,\n column: 4,\n row: 4,\n modifiers: KeyModifiers::NONE,\n },\n );\n\n assert!(events.is_empty());\n assert_eq!(app.viewport.pending_scroll_delta, 0);\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));\n}\n\n#[test]\nfn copy_shortcut_accepts_cmd_and_ctrl_shift_only() {\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::SUPER,\n )));\n assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(\n KeyCode::Char('c'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )));\n assert!(!crate::tui::key_shortcuts::is_copy_shortcut(\n &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)\n ));\n}\n\n#[test]\nfn file_tree_shortcut_does_not_steal_plain_ctrl_e() {\n assert!(!crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(KeyCode::Char('E'), KeyModifiers::CONTROL,)\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('e'),\n KeyModifiers::CONTROL | KeyModifiers::SHIFT,\n )\n ));\n assert!(crate::tui::key_shortcuts::is_file_tree_toggle_shortcut(\n &KeyEvent::new(\n KeyCode::Char('E'),\n KeyModifiers::SUPER | KeyModifiers::SHIFT,\n )\n ));\n}\n\n#[test]\nfn parse_plan_choice_accepts_numbers() {\n assert_eq!(parse_plan_choice(\"1\"), Some(PlanChoice::AcceptAgent));\n assert_eq!(parse_plan_choice(\"2\"), Some(PlanChoice::AcceptYolo));\n assert_eq!(parse_plan_choice(\"3\"), Some(PlanChoice::RevisePlan));\n assert_eq!(parse_plan_choice(\"4\"), Some(PlanChoice::ExitPlan));\n}\n\n#[test]\nfn parse_plan_choice_rejects_aliases_and_extra_text() {\n assert_eq!(parse_plan_choice(\"accept\"), None);\n assert_eq!(parse_plan_choice(\"agent\"), None);\n assert_eq!(parse_plan_choice(\"yolo\"), None);\n assert_eq!(parse_plan_choice(\"3 revise\"), None);\n assert_eq!(parse_plan_choice(\"unknown\"), None);\n}\n\n#[test]\nfn plan_choice_from_option_maps_expected_values() {\n assert_eq!(plan_choice_from_option(1), Some(PlanChoice::AcceptAgent));\n assert_eq!(plan_choice_from_option(2), Some(PlanChoice::AcceptYolo));\n assert_eq!(plan_choice_from_option(3), Some(PlanChoice::RevisePlan));\n assert_eq!(plan_choice_from_option(4), Some(PlanChoice::ExitPlan));\n assert_eq!(plan_choice_from_option(5), None);\n}\n\n#[test]\nfn plan_prompt_view_escape_emits_dismiss_event() {\n let mut view = PlanPromptView::new();\n\n let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));\n\n assert!(matches!(\n action,\n ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)\n ));\n}\n\n#[test]\nfn transcript_scroll_percent_is_clamped_and_relative() {\n assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));\n assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));\n assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));\n assert_eq!(transcript_scroll_percent(0, 20, 20), None);\n}\n\n#[test]\nfn parse_git_status_path_handles_simple_and_renamed_entries() {\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\" M crates/tui/src/tui/ui.rs\"),\n Some(\"crates/tui/src/tui/ui.rs\".to_string())\n );\n assert_eq!(\n crate::tui::file_picker_relevance::parse_git_status_path(\n \"R old name.rs -> crates/tui/src/tui/file_picker.rs\"\n ),\n Some(\"crates/tui/src/tui/file_picker.rs\".to_string())\n );\n}\n\n#[test]\nfn workspace_file_candidate_normalizes_absolute_and_line_suffixed_paths() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n let path = root.join(\"src/lib.rs\");\n std::fs::write(&path, \"\").unwrap();\n\n let raw = format!(\"\\\"{}:42\\\",\", path.display());\n assert_eq!(\n crate::tui::file_picker_relevance::workspace_file_candidate(&raw, root),\n Some(\"src/lib.rs\".to_string())\n );\n}\n\n#[test]\nfn tool_path_relevance_extracts_paths_from_command_text() {\n let dir = TempDir::new().expect(\"tempdir\");\n let root = dir.path();\n std::fs::create_dir_all(root.join(\"src\")).unwrap();\n std::fs::write(root.join(\"src/alpha.rs\"), \"\").unwrap();\n std::fs::write(root.join(\"src/zeta.rs\"), \"\").unwrap();\n\n let mut relevance = crate::tui::file_picker::FilePickerRelevance::default();\n let mut seen = HashSet::new();\n let mut budget = 16;\n crate::tui::file_picker_relevance::mark_tool_paths_from_text(\n \"sed -n '1,20p' src/zeta.rs\",\n root,\n &mut seen,\n &mut relevance,\n &mut budget,\n );\n\n let view = crate::tui::file_picker::FilePickerView::new_with_relevance(root, relevance);\n assert_eq!(view.selected_for_test(), Some(\"src/zeta.rs\"));\n}\n\nfn create_test_app() -> App {\n let options = TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n };\n let mut app = App::new(options, &Config::default());\n // Pin locale and currency for deterministic tests regardless of host locale.\n app.cost_currency = crate::pricing::CostCurrency::Usd;\n app.ui_locale = crate::localization::Locale::En;\n app\n}\n\nfn create_test_options() -> TuiOptions {\n TuiOptions {\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\".\"),\n config_path: None,\n config_profile: None,\n allow_shell: false,\n use_alt_screen: true,\n use_mouse_capture: false,\n use_bracketed_paste: true,\n max_subagents: 1,\n skills_dir: PathBuf::from(\".\"),\n memory_path: PathBuf::from(\"memory.md\"),\n notes_path: PathBuf::from(\"notes.txt\"),\n mcp_config_path: PathBuf::from(\"mcp.json\"),\n use_memory: false,\n // Keep UI tests independent from the developer's saved\n // `default_mode` setting.\n start_in_agent_mode: true,\n skip_onboarding: false,\n yolo: false,\n resume_session_id: None,\n initial_input: None,\n }\n}\n\nfn text_message(role: &str, text: &str) -> Message {\n Message {\n role: role.to_string(),\n content: vec![ContentBlock::Text {\n text: text.to_string(),\n cache_control: None,\n }],\n }\n}\n\nfn saved_session_with_messages(messages: Vec) -> SavedSession {\n SavedSession {\n schema_version: 1,\n metadata: crate::session_manager::SessionMetadata {\n id: \"resume-recovery-session\".to_string(),\n title: \"resume recovery\".to_string(),\n created_at: chrono::Utc::now(),\n updated_at: chrono::Utc::now(),\n message_count: messages.len(),\n total_tokens: 0,\n model: \"deepseek-v4-pro\".to_string(),\n workspace: PathBuf::from(\"/tmp/resume-recovery\"),\n mode: Some(\"yolo\".to_string()),\n cost: crate::session_manager::SessionCostSnapshot::default(),\n },\n messages,\n system_prompt: None,\n context_references: Vec::new(),\n artifacts: Vec::new(),\n }\n}\n\n#[test]\nfn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() {\n let mut app = create_test_app();\n let session = saved_session_with_messages(vec![text_message(\n \"user\",\n \"finish the Qthresh proof bundle\",\n )]);\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(recovered);\n assert!(app.api_messages.is_empty());\n assert_eq!(app.input, \"finish the Qthresh proof bundle\");\n assert_eq!(\n app.queued_draft\n .as_ref()\n .map(|draft| draft.display.as_str()),\n Some(\"finish the Qthresh proof bundle\")\n );\n assert!(\n app.history\n .iter()\n .all(|cell| !matches!(cell, HistoryCell::User { .. }))\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"Recovered interrupted prompt\")),\n \"status was {:?}\",\n app.status_message\n );\n}\n\n#[test]\nfn apply_loaded_session_resets_unpersisted_telemetry() {\n let mut app = create_test_app();\n app.session.session_cost = 1.25;\n app.session.session_cost_cny = 9.13;\n app.session.subagent_cost = 0.75;\n app.session.subagent_cost_cny = 5.48;\n app.session.subagent_cost_event_seqs.insert(42);\n app.session.displayed_cost_high_water = 2.0;\n app.session.displayed_cost_high_water_cny = 14.61;\n app.session.last_prompt_tokens = Some(120);\n app.session.last_completion_tokens = Some(35);\n app.session.last_prompt_cache_hit_tokens = Some(80);\n app.session.last_prompt_cache_miss_tokens = Some(40);\n app.session.last_reasoning_replay_tokens = Some(12);\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 120,\n output_tokens: 35,\n cache_hit_tokens: Some(80),\n cache_miss_tokens: Some(40),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.total_tokens = 500;\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session.total_tokens, 500);\n assert_eq!(app.session.total_conversation_tokens, 500);\n assert_eq!(app.session.session_cost, 0.0);\n assert_eq!(app.session.session_cost_cny, 0.0);\n assert_eq!(app.session.subagent_cost, 0.0);\n assert_eq!(app.session.subagent_cost_cny, 0.0);\n assert!(app.session.subagent_cost_event_seqs.is_empty());\n assert_eq!(app.session.displayed_cost_high_water, 0.0);\n assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);\n assert_eq!(app.session.last_prompt_tokens, None);\n assert_eq!(app.session.last_completion_tokens, None);\n assert_eq!(app.session.last_prompt_cache_hit_tokens, None);\n assert_eq!(app.session.last_prompt_cache_miss_tokens, None);\n assert_eq!(app.session.last_reasoning_replay_tokens, None);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn apply_loaded_session_resets_workspace_runtime_state() {\n let mut app = create_test_app();\n let config = Config::default();\n let old_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n let old_context_cell = app.workspace_context_cell.clone();\n app.workspace_context = Some(\"old workspace context\".to_string());\n if let Ok(mut cell) = old_context_cell.lock() {\n *cell = Some(\"old workspace context\".to_string());\n }\n app.workspace_context_refreshed_at = Some(Instant::now());\n app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(\n PathBuf::from(\".\").as_path(),\n ));\n\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = TempDir::new().expect(\"temp dir\").path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n\n assert!(!recovered);\n assert_eq!(app.workspace, session.metadata.workspace);\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n assert!(app.file_tree.is_none());\n assert!(old_context_cell.lock().expect(\"context cell\").is_none());\n let new_shell_manager = app\n .runtime_services\n .shell_manager\n .as_ref()\n .expect(\"shell manager\")\n .clone();\n assert!(!std::sync::Arc::ptr_eq(\n &old_shell_manager,\n &new_shell_manager\n ));\n assert_eq!(\n new_shell_manager\n .lock()\n .expect(\"shell manager\")\n .default_workspace(),\n session.metadata.workspace.as_path()\n );\n assert!(app.runtime_services.hook_executor.is_some());\n}\n\n#[test]\nfn apply_loaded_session_updates_current_workspace_display() {\n let mut app = create_test_app();\n let config = Config::default();\n let workspace = TempDir::new().expect(\"temp dir\");\n let mut session = saved_session_with_messages(vec![text_message(\"assistant\", \"ready\")]);\n session.metadata.workspace = workspace.path().to_path_buf();\n\n let recovered = apply_loaded_session(&mut app, &config, &session);\n let result = commands::execute(\"/workspace\", &mut app);\n\n assert!(!recovered);\n assert_eq!(\n result.message,\n Some(format!(\"Current workspace: {}\", workspace.path().display()))\n );\n assert!(result.action.is_none());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_applies_draft_without_closing_session() {\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Draft(doc))\n .expect(\"send draft\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(keep);\n assert!(session.is_some());\n}\n\n#[tokio::test]\nasync fn drain_web_config_events_closes_session_after_commit() {\n let _config_env = ConfigPathEnvGuard::new();\n let mut app = create_test_app();\n let mut config = Config::default();\n let engine = mock_engine_handle();\n let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n let doc = config_ui::build_document(&app, &config).expect(\"document\");\n tx.send(WebConfigSessionEvent::Committed(doc))\n .expect(\"send commit\");\n let mut session = Some(WebConfigSession::for_test(rx));\n\n let keep = drain_web_config_events(&mut session, &mut app, &mut config, &engine.handle).await;\n\n assert!(!keep);\n}\n\n#[test]\nfn backtrack_prefill_rehydrates_attachment_rows() {\n let mut app = create_test_app();\n let user_text = \"inspect this\\n[Attached image: /tmp/pasted.png]\";\n app.add_message(HistoryCell::User {\n content: user_text.to_string(),\n });\n app.api_messages.push(Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: user_text.to_string(),\n cache_control: None,\n }],\n });\n app.add_message(HistoryCell::Assistant {\n content: \"done\".to_string(),\n streaming: false,\n });\n app.api_messages.push(Message {\n role: \"assistant\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"done\".to_string(),\n cache_control: None,\n }],\n });\n\n apply_backtrack(&mut app, 0);\n\n assert_eq!(app.input, user_text);\n assert_eq!(app.composer_attachment_count(), 1);\n}\n\n#[test]\nfn active_tool_status_label_summarizes_live_tool_group() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cargo test --workspace --all-features\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"grep_files\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"pattern: TODO\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"cargo test\"));\n assert!(label.contains(\"1 active\"));\n assert!(label.contains(\"1 done\"));\n assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label()));\n}\n\n#[test]\nfn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"exec-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI\"\n .to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: app.turn_started_at,\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: None,\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"gh pr checks 1611\"), \"label: {label}\");\n assert!(!label.contains(\"cd /tmp\"), \"label: {label}\");\n assert!(!label.contains(\"sleep 15\"), \"label: {label}\");\n}\n\n#[test]\nfn active_tool_status_label_counts_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"rlm-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"task: compare projects\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let label = active_tool_status_label(&app).expect(\"status label\");\n\n assert!(label.contains(\"tool rlm\"), \"label: {label}\");\n assert!(label.contains(\"1 active\"), \"label: {label}\");\n}\n\n#[test]\nfn terminal_probe_timeout_defaults_to_500ms() {\n let config = Config::default();\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(500));\n}\n\n#[test]\nfn terminal_probe_timeout_uses_tui_config_and_clamps() {\n let mut config = Config {\n tui: Some(crate::config::TuiConfig {\n alternate_screen: None,\n mouse_capture: None,\n terminal_probe_timeout_ms: Some(750),\n status_items: None,\n osc8_links: None,\n notification_condition: None,\n composer_arrows_scroll: None,\n }),\n ..Config::default()\n };\n\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(750));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(0);\n assert_eq!(terminal_probe_timeout(&config), Duration::from_millis(100));\n\n config\n .tui\n .as_mut()\n .expect(\"tui config\")\n .terminal_probe_timeout_ms = Some(60_000);\n assert_eq!(\n terminal_probe_timeout(&config),\n Duration::from_millis(5_000)\n );\n}\n\n#[test]\nfn file_mentions_add_local_text_context_to_model_payload() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(\n tmpdir.path().join(\"guide.md\"),\n \"# Guide\\nUse the fast path.\\n\",\n )\n .expect(\"write file\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n let message = QueuedMessage::new(\"Summarize @guide.md\".to_string(), None);\n\n let content = queued_message_content_for_app(&app, &message, None);\n\n assert!(content.starts_with(\"Summarize @guide.md\"));\n assert!(content.contains(\"Local context from @mentions:\"));\n assert!(content.contains(\"large\";\n\n assert_eq!(compact_user_context_display(content), \"Summarize @guide.md\");\n}\n\n#[test]\nfn file_mentions_do_not_trigger_inside_email_addresses() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"example.com\"), \"not a mention\").expect(\"write file\");\n\n let content = user_request_with_file_mentions(\"email me@example.com\", tmpdir.path(), None);\n\n assert_eq!(content, \"email me@example.com\");\n}\n\n#[test]\nfn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"photo.png\"), b\"\\0png\").expect(\"write image\");\n\n let content = user_request_with_file_mentions(\"inspect @photo.png\", tmpdir.path(), None);\n\n assert!(content.contains(\" {\n assert_eq!(model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetModel, got {other:?}\"),\n }\n\n match engine.rx_op.recv().await.expect(\"set compaction op\") {\n crate::core::ops::Op::SetCompaction { config } => {\n assert_eq!(config.model, \"deepseek-v4-flash\");\n }\n other => panic!(\"expected SetCompaction, got {other:?}\"),\n }\n}\n\n#[tokio::test]\nasync fn provider_switch_clears_turn_cache_history() {\n // `switch_provider` persists the new provider to `Settings`, which\n // writes through `dirs::data_dir()` (`~/Library/Application\n // Support/deepseek/settings.toml` on macOS). Without redirecting\n // HOME / USERPROFILE we would clobber the developer's real\n // preferences and leave `default_provider = \"ollama\"` behind —\n // which then leaks into any subsequent test that constructs an\n // `App`. Hold the process-wide env lock for the duration so we\n // serialize with other tests that mutate the same env vars.\n // Wrap the lock inside a guard struct so clippy's\n // `await_holding_lock` doesn't fire on the `.await` below; the\n // pattern matches `tools::recall_archive::HomeGuard`.\n struct HomeGuard {\n _tmp: tempfile::TempDir,\n prev_home: Option,\n prev_userprofile: Option,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for HomeGuard {\n fn drop(&mut self) {\n // SAFETY: still holding the process-wide env lock.\n unsafe {\n match self.prev_home.take() {\n Some(v) => std::env::set_var(\"HOME\", v),\n None => std::env::remove_var(\"HOME\"),\n }\n match self.prev_userprofile.take() {\n Some(v) => std::env::set_var(\"USERPROFILE\", v),\n None => std::env::remove_var(\"USERPROFILE\"),\n }\n }\n }\n }\n let _home = {\n let lock = crate::test_support::lock_test_env();\n let tmp = tempfile::TempDir::new().expect(\"tempdir\");\n let prev_home = std::env::var_os(\"HOME\");\n let prev_userprofile = std::env::var_os(\"USERPROFILE\");\n // SAFETY: serialized by the process-wide test env lock.\n unsafe {\n std::env::set_var(\"HOME\", tmp.path());\n std::env::set_var(\"USERPROFILE\", tmp.path());\n }\n HomeGuard {\n _tmp: tmp,\n prev_home,\n prev_userprofile,\n _lock: lock,\n }\n };\n\n let mut app = create_test_app();\n app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {\n input_tokens: 100,\n output_tokens: 25,\n cache_hit_tokens: Some(70),\n cache_miss_tokens: Some(30),\n reasoning_replay_tokens: Some(12),\n recorded_at: Instant::now(),\n });\n let mut engine = mock_engine_handle();\n let mut config = Config::default();\n\n switch_provider(\n &mut app,\n &mut engine.handle,\n &mut config,\n ApiProvider::Ollama,\n None,\n )\n .await;\n\n assert_eq!(app.api_provider, ApiProvider::Ollama);\n assert!(app.session.turn_cache_history.is_empty());\n}\n\n#[tokio::test]\nasync fn dispatch_user_message_failed_send_clears_loading_state() {\n let mut app = create_test_app();\n let engine = mock_engine_handle();\n let config = Config::default();\n drop(engine.rx_op);\n\n let result = dispatch_user_message(\n &mut app,\n &config,\n &engine.handle,\n QueuedMessage::new(\"hello\".to_string(), None),\n )\n .await;\n\n assert!(\n result.is_err(),\n \"dispatch should fail when engine channel is closed\"\n );\n assert!(\n !app.is_loading,\n \"failed dispatch must not leave the composer in a permanent busy state\"\n );\n assert!(app.last_send_at.is_none());\n assert!(app.dispatch_started_at.is_none());\n}\n\n#[test]\nfn turn_liveness_watchdog_clears_stale_dispatch() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_millis(1));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"watchdog toast\");\n assert_eq!(toast.level, StatusToastLevel::Error);\n assert!(toast.text.contains(\"Turn dispatch timed out\"));\n}\n\n#[test]\nfn turn_liveness_reconciles_completed_busy_state() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"completed\".to_string());\n app.dispatch_started_at = Some(Instant::now());\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(recovered);\n assert!(!app.is_loading);\n assert!(app.dispatch_started_at.is_none());\n let toast = app.status_toasts.back().expect(\"reconciliation toast\");\n assert_eq!(toast.level, StatusToastLevel::Warning);\n assert!(\n toast\n .text\n .contains(\"Recovered from an inconsistent busy state\")\n );\n}\n\n#[test]\nfn turn_liveness_leaves_active_turn_running() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n app.dispatch_started_at =\n Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10));\n\n let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false);\n\n assert!(!recovered);\n assert!(app.is_loading);\n assert!(app.dispatch_started_at.is_some());\n assert!(app.status_toasts.is_empty());\n}\n\n#[test]\nfn fixed_model_auto_thinking_skips_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = false;\n app.model = \"deepseek-v4-pro\".to_string();\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n !crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"fixed-model auto thinking must stay local instead of starting a hidden router request\"\n );\n}\n\n#[test]\nfn auto_model_still_uses_auto_model_router() {\n let mut app = create_test_app();\n app.auto_model = true;\n app.reasoning_effort = ReasoningEffort::Auto;\n\n assert!(\n crate::tui::auto_router::should_resolve_auto_model_selection(&app),\n \"auto model still needs the router to choose the concrete model\"\n );\n}\n\nfn init_git_repo() -> TempDir {\n let dir = tempfile::tempdir().expect(\"tempdir\");\n\n let init = Command::new(\"git\")\n .arg(\"init\")\n .current_dir(dir.path())\n .output()\n .expect(\"git init should run\");\n assert!(\n init.status.success(),\n \"git init failed: {}\",\n String::from_utf8_lossy(&init.stderr)\n );\n\n let commit = Command::new(\"git\")\n .args([\n \"-c\",\n \"user.name=DeepSeek TUI Tests\",\n \"-c\",\n \"user.email=tests@example.com\",\n \"commit\",\n \"--allow-empty\",\n \"-m\",\n \"init\",\n ])\n .current_dir(dir.path())\n .output()\n .expect(\"git commit should run\");\n assert!(\n commit.status.success(),\n \"git commit failed: {}\",\n String::from_utf8_lossy(&commit.stderr)\n );\n\n dir\n}\n\nfn spans_text(spans: &[Span<'_>]) -> String {\n spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n}\n\n#[test]\nfn alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() {\n let mut app = create_test_app();\n app.mode = AppMode::Agent;\n app.sidebar_focus = SidebarFocus::Auto;\n\n apply_alt_4_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.mode, AppMode::Agent);\n assert_eq!(app.sidebar_focus, SidebarFocus::Agents);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: agents\"));\n}\n\n#[test]\nfn alt_0_restores_auto_sidebar_focus() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn ctrl_alt_0_hides_sidebar() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Tasks;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar hidden\"));\n}\n\n#[test]\nfn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {\n let mut app = create_test_app();\n app.sidebar_focus = SidebarFocus::Hidden;\n\n apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);\n\n assert_eq!(app.sidebar_focus, SidebarFocus::Auto);\n assert_eq!(app.status_message.as_deref(), Some(\"Sidebar focus: auto\"));\n}\n\n#[test]\nfn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {\n let mut app = create_test_app();\n app.sidebar_width_percent = 28;\n\n app.sidebar_focus = SidebarFocus::Auto;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), Some(33));\n\n app.sidebar_focus = SidebarFocus::Hidden;\n assert_eq!(sidebar_width_for_chat_area(&app, 120), None);\n}\n\nfn make_subagent(\n id: &str,\n status: crate::tools::subagent::SubAgentStatus,\n) -> crate::tools::subagent::SubAgentResult {\n crate::tools::subagent::SubAgentResult {\n name: id.to_string(),\n agent_id: id.to_string(),\n context_mode: \"fresh\".to_string(),\n fork_context: false,\n agent_type: crate::tools::subagent::SubAgentType::General,\n assignment: crate::tools::subagent::SubAgentAssignment {\n objective: format!(\"objective-{id}\"),\n role: Some(\"worker\".to_string()),\n },\n model: \"deepseek-v4-flash\".to_string(),\n nickname: None,\n status,\n result: None,\n steps_taken: 0,\n duration_ms: 0,\n from_prior_session: false,\n }\n}\n\n#[test]\nfn sort_subagents_orders_running_before_terminal_statuses() {\n let mut agents = vec![\n make_subagent(\"agent_c\", crate::tools::subagent::SubAgentStatus::Completed),\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\n \"agent_b\",\n crate::tools::subagent::SubAgentStatus::Failed(\"boom\".to_string()),\n ),\n ];\n\n sort_subagents_in_place(&mut agents);\n\n assert_eq!(agents[0].agent_id, \"agent_a\");\n assert_eq!(agents[1].agent_id, \"agent_b\");\n assert_eq!(agents[2].agent_id, \"agent_c\");\n}\n\n#[test]\nfn running_agent_count_unions_cache_and_progress() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_c\".to_string(), \"planning\".to_string());\n\n assert_eq!(running_agent_count(&app), 2);\n}\n\n#[test]\nfn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {\n let mut app = create_test_app();\n app.subagent_cache = vec![\n make_subagent(\"agent_a\", crate::tools::subagent::SubAgentStatus::Running),\n make_subagent(\"agent_b\", crate::tools::subagent::SubAgentStatus::Completed),\n ];\n app.agent_progress\n .insert(\"agent_stale\".to_string(), \"old\".to_string());\n\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.contains_key(\"agent_a\"));\n assert!(!app.agent_progress.contains_key(\"agent_stale\"));\n assert!(app.agent_activity_started_at.is_some());\n\n app.subagent_cache.clear();\n reconcile_subagent_activity_state(&mut app);\n assert!(app.agent_progress.is_empty());\n assert!(app.agent_activity_started_at.is_none());\n}\n\n#[test]\nfn subagent_token_usage_updates_live_cost_counter_without_card_change() {\n let mut app = create_test_app();\n handle_subagent_mailbox(\n &mut app,\n 1,\n &crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n },\n );\n\n assert!(app.session.subagent_cost > 0.0);\n assert!(\n app.history.is_empty(),\n \"usage-only mailbox messages should not allocate a sub-agent card\"\n );\n}\n\n#[test]\nfn subagent_token_usage_is_deduped_by_mailbox_sequence() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-a\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n\n handle_subagent_mailbox(&mut app, 7, &usage);\n let first = app.session.subagent_cost;\n handle_subagent_mailbox(&mut app, 7, &usage);\n assert_eq!(app.session.subagent_cost, first);\n handle_subagent_mailbox(&mut app, 8, &usage);\n assert!(app.session.subagent_cost > first);\n}\n\n#[test]\nfn format_token_count_compact_formats_units() {\n assert_eq!(format_token_count_compact(999), \"999\");\n assert_eq!(format_token_count_compact(1_200), \"1.2k\");\n assert_eq!(format_token_count_compact(1_000_000), \"1.0M\");\n}\n\n#[test]\nfn format_context_budget_caps_overflow_display() {\n assert_eq!(format_context_budget(5_000, 128_000), \"5.0k/128.0k\");\n assert_eq!(format_context_budget(250_000, 128_000), \">128.0k/128.0k\");\n}\n\n#[test]\nfn footer_state_label_drops_thinking_and_prefers_compacting() {\n // We deliberately do not surface a \"thinking\" label for `is_loading` —\n // the animated water-spout strip in the footer's spacer is the visual\n // signal. `is_loading` alone falls through to \"ready\"; `is_compacting`\n // still wins because compacting is a less-common, distinct state.\n let mut app = create_test_app();\n assert_eq!(footer_state_label(&app).0, \"ready\");\n\n app.is_loading = true;\n assert_eq!(\n footer_state_label(&app).0,\n \"ready\",\n \"is_loading must NOT produce a `thinking` text label — the animation handles it\"\n );\n\n app.is_compacting = true;\n assert!(footer_state_label(&app).0.starts_with(\"compacting\"));\n}\n\n#[test]\nfn event_poll_timeout_has_nonzero_floor() {\n assert_eq!(\n clamp_event_poll_timeout(Duration::ZERO),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_micros(250)),\n Duration::from_millis(1)\n );\n assert_eq!(\n clamp_event_poll_timeout(Duration::from_millis(24)),\n Duration::from_millis(24)\n );\n}\n\n#[test]\nfn footer_status_line_spans_show_mode_and_model_idle_and_active() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-flash\".to_string();\n // Pin Agent mode regardless of user settings on the host machine.\n let _ = app.set_mode(crate::tui::app::AppMode::Agent);\n\n let idle = spans_text(&footer_status_line_spans(&app, 60));\n assert!(idle.contains(\"agent\"));\n assert!(idle.contains(\"deepseek-v4-flash\"));\n assert!(idle.contains(\"\\u{00B7}\"));\n assert!(!idle.contains(\"ready\"));\n\n // is_loading no longer adds a \"thinking\" text label — the live-work\n // signal is the animated water-spout strip the renderer paints into\n // the footer's spacer. The mode + model still render unchanged.\n app.is_loading = true;\n let active = spans_text(&footer_status_line_spans(&app, 60));\n assert!(active.contains(\"agent\"));\n assert!(active.contains(\"deepseek-v4-flash\"));\n assert!(\n !active.contains(\"thinking\"),\n \"footer must not show a `thinking` text label while loading\"\n );\n}\n\n#[test]\nfn footer_status_line_spans_truncate_long_model_names() {\n let mut app = create_test_app();\n app.model = \"deepseek-v4-pro-with-an-extremely-long-model-name\".to_string();\n app.is_loading = true;\n\n let line = spans_text(&footer_status_line_spans(&app, 40));\n assert!(line.contains(\"...\"));\n assert!(UnicodeWidthStr::width(line.as_str()) <= 40);\n}\n\n#[test]\nfn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {\n let mut app = create_test_app();\n\n app.coherence_state = crate::core::coherence::CoherenceState::Healthy;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"healthy state should produce no footer chip\"\n );\n\n // GettingCrowded is intentionally suppressed — see the rationale in\n // `footer_coherence_spans`. The footer only surfaces active engine\n // interventions; soft pressure hints stay quiet.\n app.coherence_state = crate::core::coherence::CoherenceState::GettingCrowded;\n assert!(\n footer_coherence_spans(&app).is_empty(),\n \"GettingCrowded should not surface a footer chip; only active interventions do\"\n );\n\n let cases = [\n (\n crate::core::coherence::CoherenceState::RefreshingContext,\n \"refreshing context\",\n ),\n (\n crate::core::coherence::CoherenceState::VerifyingRecentWork,\n \"verifying\",\n ),\n (\n crate::core::coherence::CoherenceState::ResettingPlan,\n \"resetting plan\",\n ),\n ];\n\n for (state, expected) in cases {\n app.coherence_state = state;\n assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);\n }\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_when_compact() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let compact = spans_text(&footer_auxiliary_spans(&app, 48));\n assert!(compact.contains(\"Cache: 75.0% hit\"));\n assert!(!compact.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_completion_tokens = Some(2_000);\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n\n assert!(roomy.contains(\"Cache: unavailable\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_prompt_cache_hit_tokens = Some(36_000);\n app.session.last_prompt_cache_miss_tokens = Some(12_000);\n app.session.session_cost = 12.34;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 72));\n assert!(roomy.contains(\"Cache: 75.0% hit | hit 36000 | miss 12000\"));\n assert!(roomy.contains(\"$12.34\"));\n assert!(\n !roomy.contains(\"ctx\"),\n \"context % removed from footer — shown in header only\"\n );\n}\n\n#[test]\nfn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans_text(&spans), \"Cache: 5.0% hit | hit 500 | miss 9500\");\n assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));\n}\n\n#[test]\nfn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(500);\n app.session.last_prompt_cache_miss_tokens = Some(9_500);\n app.prefix_stability_pct = Some(80);\n app.prefix_change_count = 2;\n\n let spans = footer_cache_spans(&app);\n\n assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"<$0.0001\"));\n}\n\n#[test]\nfn footer_auxiliary_spans_use_configured_cost_currency() {\n let mut app = create_test_app();\n app.cost_currency = crate::pricing::CostCurrency::Cny;\n app.session.session_cost_cny = 2.5;\n\n let roomy = spans_text(&footer_auxiliary_spans(&app, 32));\n assert!(roomy.contains(\"¥2.50\"));\n assert!(!roomy.contains('$'));\n}\n\n#[test]\nfn footer_auxiliary_spans_show_reasoning_replay_chip() {\n // Issue #30: when a thinking-mode tool-calling turn replays prior\n // reasoning_content, the footer surfaces the approximate input-token\n // cost so users can see why their context filled up.\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(8_200);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(\n text.contains(\"rsn 8.2k\"),\n \"expected replay chip, got {text:?}\"\n );\n}\n\n#[test]\nfn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(48_000);\n app.session.last_reasoning_replay_tokens = Some(0);\n\n let spans = footer_auxiliary_spans(&app, 64);\n let text = spans_text(&spans);\n assert!(!text.contains(\"rsn\"), \"zero replay must not render chip\");\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(1_200_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"hello\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used > 0);\n assert!(used <= i64::from(max));\n assert!(percent < 100.0);\n}\n\n#[test]\nfn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {\n let mut app = create_test_app();\n app.session.last_prompt_tokens = Some(980_000);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small current context\".to_string(),\n cache_control: None,\n }],\n }];\n\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(max, 1_000_000);\n assert!(used < 10_000);\n assert!(percent < 2.0);\n}\n\n/// Regression for #115. The engine sums `input_tokens` across every round\n/// of a turn (`turn.add_usage` does `+=`), so a multi-round tool-call turn\n/// reports a value much larger than the actual context window state, then\n/// the next single-round turn drops back to a single round's input_tokens.\n/// User-visible % was bouncing 31% → 9% because of this. The fix is to\n/// prefer the estimated current-context size, which is monotonic wrt\n/// conversation growth.\n#[test]\nfn context_usage_does_not_drop_when_reported_shrinks_after_multi_round_turn() {\n let mut app = create_test_app();\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(2_000), // ~14k tokens estimated\n cache_control: None,\n }],\n }];\n\n // Simulate a multi-round turn that summed two rounds' input_tokens\n // (e.g., 200k + 210k from a long thinking + tool-call sequence).\n app.session.last_prompt_tokens = Some(410_000);\n let (_, _, percent_after_multi_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // Now the next turn is a single round on the same conversation —\n // reported drops to one round's worth even though the actual context\n // hasn't shrunk.\n app.session.last_prompt_tokens = Some(15_000);\n let (_, _, percent_after_single_round) = context_usage_snapshot(&app).expect(\"usage available\");\n\n // The displayed % should reflect the conversation size (estimated\n // from api_messages), NOT the wildly variable reported value.\n let drift = (percent_after_multi_round - percent_after_single_round).abs();\n assert!(\n drift < 1.0,\n \"displayed % should not jump because reported tokens varied across rounds; \\\n after-multi-round={percent_after_multi_round:.2} after-single-round={percent_after_single_round:.2}\"\n );\n}\n\n#[test]\nfn context_usage_snapshot_prefers_live_estimate_while_loading() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.session.last_prompt_tokens = Some(128);\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(6_000),\n cache_control: None,\n }],\n }];\n\n let estimated = estimated_context_tokens(&app).expect(\"estimated context should be available\");\n let (used, max, percent) =\n context_usage_snapshot(&app).expect(\"context usage should be available\");\n assert_eq!(used, estimated);\n assert_eq!(max, 1_000_000);\n assert!(used > i64::from(app.session.last_prompt_tokens.expect(\"reported tokens\")));\n assert!(percent > 0.0);\n}\n\n#[test]\nfn should_auto_compact_before_send_respects_threshold_and_setting() {\n let mut app = create_test_app();\n let big_buffer = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"context \".repeat(400_000),\n cache_control: None,\n }],\n }];\n\n // High estimated context + auto_compact ON → auto-compact triggers.\n app.api_messages = big_buffer.clone();\n app.auto_compact = true;\n assert!(should_auto_compact_before_send(&app));\n\n // Same high context but auto_compact OFF → never triggers.\n app.auto_compact = false;\n assert!(!should_auto_compact_before_send(&app));\n\n // Small estimated context + auto_compact ON → does NOT trigger,\n // regardless of what `last_prompt_tokens` reports. This matches the\n // #115 fix: the estimate is the primary signal, not the engine's\n // turn-cumulative reported value (which used to rule the displayed\n // % and could spuriously trigger / suppress auto-compact).\n app.api_messages = vec![Message {\n role: \"user\".to_string(),\n content: vec![ContentBlock::Text {\n text: \"small\".to_string(),\n cache_control: None,\n }],\n }];\n app.auto_compact = true;\n app.session.last_prompt_tokens = Some(10_000);\n assert!(!should_auto_compact_before_send(&app));\n}\n\n// ============================================================================\n// Streaming Cancel Behavior Tests\n// ============================================================================\n\n#[test]\nfn test_esc_cancels_streaming_sets_is_loading_false() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.mode = AppMode::Agent;\n\n // Simulate what happens in ui.rs when Esc is pressed during loading:\n // engine_handle.cancel() is called (can't test directly - private)\n // Then these state changes occur:\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_esc_with_input_clears_input_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"some draft input\".to_string();\n app.cursor_position = app.input.chars().count();\n\n // Simulate Esc key press when not loading but input not empty\n app.clear_input();\n\n assert!(app.input.is_empty());\n assert_eq!(app.cursor_position, 0);\n assert!(!app.is_loading);\n}\n\n#[test]\nfn test_esc_discards_queued_draft_before_clearing_input() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n}\n\n#[test]\nfn test_esc_is_noop_when_idle() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n app.cursor_position = 0;\n app.mode = AppMode::Agent;\n\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n assert_eq!(app.mode, AppMode::Agent);\n}\n\n#[test]\nfn test_esc_closes_slash_menu_before_other_actions() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn history_arrow_does_not_steal_open_menus() {\n let mut app = create_test_app();\n app.input_history.push(\"previous prompt\".to_string());\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n assert!(!handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n true,\n false,\n ));\n\n assert_eq!(app.input, \"/\");\n assert!(app.history_index.is_none());\n}\n\n#[test]\nfn test_ctrl_c_cancels_streaming_sets_status() {\n let mut app = create_test_app();\n app.is_loading = true;\n\n // Simulate Ctrl+C during loading state\n // engine_handle.cancel() is called (can't test directly - private)\n app.is_loading = false;\n app.status_message = Some(\"Request cancelled\".to_string());\n\n assert!(!app.is_loading);\n assert_eq!(app.status_message, Some(\"Request cancelled\".to_string()));\n}\n\n#[test]\nfn test_ctrl_c_exits_when_not_loading() {\n let mut app = create_test_app();\n app.is_loading = false;\n\n // Ctrl+C when not loading should trigger shutdown\n // We can't test the actual shutdown, but verify the state is correct\n // for the shutdown path to be taken\n assert!(!app.is_loading);\n}\n\n#[test]\nfn ctrl_c_disposition_idle_arms_exit_prompt() {\n let app = create_test_app();\n assert!(!app.is_loading);\n assert!(!app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ArmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_cancels_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_armed_idle_confirms_exit() {\n let mut app = create_test_app();\n app.arm_quit();\n assert!(app.quit_is_armed());\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::ConfirmExit);\n}\n\n#[test]\nfn ctrl_c_disposition_loading_beats_armed_quit() {\n // If a turn started while quit is armed, the user almost certainly meant\n // \"cancel the turn\", not \"exit\". Pin that priority order.\n let mut app = create_test_app();\n app.arm_quit();\n app.is_loading = true;\n assert_eq!(ctrl_c_disposition(&app), CtrlCDisposition::CancelTurn);\n}\n\n#[test]\nfn ctrl_c_disposition_no_selection_means_no_copy() {\n // Regression guard for #1337: with no transcript selection, Ctrl+C must\n // NOT route to copy. (When selection is active, the copy branch wins;\n // exercised by the integration-level mouse-drag tests in this file.)\n let app = create_test_app();\n assert!(!selection_has_content(&app));\n assert_ne!(ctrl_c_disposition(&app), CtrlCDisposition::CopySelection);\n}\n\n#[test]\nfn test_ctrl_d_exits_when_input_empty() {\n let mut app = create_test_app();\n app.input.clear();\n\n // Ctrl+D when input empty should trigger shutdown\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn test_ctrl_d_does_nothing_when_input_not_empty() {\n let mut app = create_test_app();\n app.input = \"some input\".to_string();\n\n // Ctrl+D when input not empty should not trigger shutdown\n assert!(!app.input.is_empty());\n}\n\n#[test]\nfn test_esc_priority_order_matches_cancel_stack() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"draft\".to_string();\n app.mode = AppMode::Yolo;\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n\n app.input.clear();\n app.queued_draft = Some(crate::tui::app::QueuedMessage::new(\n \"queued draft\".to_string(),\n None,\n ));\n assert_eq!(\n next_escape_action(&app, false),\n EscapeAction::DiscardQueuedDraft\n );\n\n app.queued_draft = None;\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn visible_slash_menu_entries_respects_hide_flag() {\n let mut app = create_test_app();\n app.input = \"/mo\".to_string();\n app.slash_menu_hidden = false;\n\n let entries = visible_slash_menu_entries(&app, 6);\n assert!(!entries.is_empty());\n\n app.slash_menu_hidden = true;\n let hidden_entries = visible_slash_menu_entries(&app, 6);\n assert!(hidden_entries.is_empty());\n}\n\n#[test]\nfn visible_slash_menu_entries_excludes_removed_commands() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.iter().any(|entry| entry.name == \"/config\"));\n assert!(entries.iter().any(|entry| entry.name == \"/links\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/set\"));\n assert!(!entries.iter().any(|entry| entry.name == \"/deepseek\"));\n}\n\n#[test]\nfn slash_menu_up_wraps_from_first_to_last() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n app.input_history.push(\"previous prompt\".to_string());\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = 0;\n select_previous_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, entries.len() - 1);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn slash_menu_down_wraps_from_last_to_first() {\n let mut app = create_test_app();\n app.input = \"/\".to_string();\n app.cursor_position = 1;\n\n let entries = visible_slash_menu_entries(&app, 128);\n assert!(entries.len() > 1);\n\n app.slash_menu_selected = entries.len() - 1;\n select_next_slash_menu_entry(&mut app, entries.len());\n\n assert_eq!(app.slash_menu_selected, 0);\n assert_eq!(app.input, \"/\");\n}\n\n#[test]\nfn apply_slash_menu_selection_appends_space_for_arg_commands() {\n let mut app = create_test_app();\n let entries = vec![\n crate::tui::widgets::SlashMenuEntry {\n name: \"/model\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n crate::tui::widgets::SlashMenuEntry {\n name: \"/settings\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n },\n ];\n app.slash_menu_selected = 0;\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/model \");\n}\n\n#[test]\nfn apply_slash_menu_selection_keeps_change_executable_without_version() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/change\".to_string(),\n description: String::new(),\n is_skill: false,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/change\");\n}\n\n#[test]\nfn apply_slash_menu_selection_uses_skill_command_form() {\n let mut app = create_test_app();\n let entries = vec![crate::tui::widgets::SlashMenuEntry {\n name: \"/skill search-files\".to_string(),\n description: \"Search files\".to_string(),\n is_skill: true,\n alias_hint: None,\n }];\n\n assert!(apply_slash_menu_selection(&mut app, &entries, true));\n assert_eq!(app.input, \"/skill search-files\");\n}\n\n#[test]\nfn try_autocomplete_slash_command_completes_skill_argument() {\n let mut app = create_test_app();\n app.cached_skills = vec![\n (\"search-files\".to_string(), \"Search files\".to_string()),\n (\"my-review\".to_string(), \"Review code\".to_string()),\n ];\n app.input = \"/skill my\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_slash_command(&mut app));\n assert_eq!(app.input, \"/skill my-review\");\n}\n\n#[test]\nfn workspace_context_refresh_is_deferred_while_ui_is_busy() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let now = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);\n\n assert!(app.workspace_context.is_none());\n assert!(app.workspace_context_refreshed_at.is_none());\n\n crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);\n\n let context = app\n .workspace_context\n .as_deref()\n .expect(\"idle refresh should populate workspace context\");\n assert!(context.contains(\"clean\"));\n assert_eq!(app.workspace_context_refreshed_at, Some(now));\n}\n\n#[test]\nfn workspace_context_refresh_respects_ttl_before_requerying_git() {\n let repo = init_git_repo();\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let start = Instant::now();\n crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);\n let initial = app\n .workspace_context\n .clone()\n .expect(\"initial refresh should populate context\");\n\n std::fs::write(repo.path().join(\"dirty.txt\"), \"dirty\").expect(\"write dirty marker\");\n\n let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);\n crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);\n assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));\n\n let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);\n crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);\n let refreshed = app\n .workspace_context\n .as_deref()\n .expect(\"refresh after ttl should update context\");\n assert!(refreshed.contains(\"untracked\"));\n assert_ne!(refreshed, initial);\n}\n\n#[tokio::test]\nasync fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.offline_mode = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"yolo\")\n .await\n .expect(\"plan choice\");\n\n assert!(!handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Plan);\n\n let queued = build_queued_message(&mut app, \"yolo\".to_string());\n submit_or_steer_message(&mut app, &config, &engine.handle, queued)\n .await\n .expect(\"submit normal message\");\n\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"yolo\".to_string())\n );\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"Offline: 1 queued — ↑ to edit, /queue list\")\n );\n}\n\n#[tokio::test]\nasync fn numeric_plan_choice_still_queues_follow_up_when_busy() {\n let mut app = create_test_app();\n app.mode = AppMode::Plan;\n app.plan_prompt_pending = true;\n app.is_loading = true;\n\n let engine = crate::core::engine::mock_engine_handle();\n let config = Config::default();\n\n let handled = handle_plan_choice(&mut app, &config, &engine.handle, \"2\")\n .await\n .expect(\"plan choice\");\n\n assert!(handled);\n assert!(!app.plan_prompt_pending);\n assert_eq!(app.mode, AppMode::Yolo);\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages\n .front()\n .map(crate::tui::app::QueuedMessage::content),\n Some(\"Proceed with the accepted plan.\".to_string())\n );\n}\n\n#[test]\nfn api_key_validation_warns_without_blocking_unusual_formats() {\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk short\"),\n crate::tui::onboarding::ApiKeyValidation::Reject(_)\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"short-key\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"averylongkeywithoutdash123456\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: Some(_) }\n ));\n assert!(matches!(\n crate::tui::onboarding::validate_api_key_for_onboarding(\"sk-valid-format-1234567890\"),\n crate::tui::onboarding::ApiKeyValidation::Accept { warning: None }\n ));\n}\n\n#[test]\nfn onboarding_after_api_key_save_does_not_repeat_language_step() {\n let mut app = create_test_app();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = true;\n app.status_message = Some(\"saved\".to_string());\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::Tips);\n assert_eq!(app.status_message, None);\n}\n\n#[test]\nfn onboarding_after_api_key_save_routes_to_trust_when_needed() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.onboarding = OnboardingState::ApiKey;\n app.onboarding_needs_api_key = false;\n app.trust_mode = false;\n\n crate::tui::onboarding::advance_onboarding_after_language(&mut app);\n\n assert_eq!(app.onboarding, OnboardingState::TrustDirectory);\n}\n\n#[test]\nfn api_key_paste_shortcut_is_not_plain_text_input() {\n let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(&ctrl_v));\n\n let legacy_ctrl_v = KeyEvent::new(KeyCode::Char('\\u{16}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_paste_shortcut(&legacy_ctrl_v));\n assert!(!crate::tui::key_shortcuts::is_text_input_key(\n &legacy_ctrl_v\n ));\n\n let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);\n assert!(crate::tui::key_shortcuts::is_text_input_key(&shifted));\n}\n\n#[test]\nfn jump_to_adjacent_tool_cell_finds_next_and_previous() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"run_command\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.mark_history_updated();\n let cell_revisions = vec![app.history_version; app.history.len()];\n app.viewport.transcript_cache.ensure(\n &app.history,\n &cell_revisions,\n 100,\n app.transcript_render_options(),\n );\n\n app.viewport.last_transcript_top = 0;\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Forward\n ));\n // Forward jump pins the scroll to a non-tail line offset (the tool\n // cell's first line). Anything below the live tail is acceptable —\n // the previous assertion checked `TranscriptScroll::Scrolled { .. }`,\n // which under the new flat-offset model means \"not at tail.\"\n assert!(!app.viewport.transcript_scroll.is_at_tail());\n\n app.viewport.last_transcript_top = app\n .viewport\n .transcript_cache\n .total_lines()\n .saturating_sub(1);\n assert!(jump_to_adjacent_tool_cell(\n &mut app,\n SearchDirection::Backward\n ));\n}\n\nfn first_line_for_cell(app: &App, cell_index: usize) -> usize {\n app.viewport\n .transcript_cache\n .line_meta()\n .iter()\n .position(|meta| meta.cell_line().is_some_and(|(idx, _)| idx == cell_index))\n .expect(\"cell should have rendered line\")\n}\n\nfn pop_pager_body(app: &mut App) -> String {\n let mut view = app.view_stack.pop().expect(\"pager view\");\n let pager = view\n .as_any_mut()\n .downcast_mut::()\n .expect(\"top view should be pager\");\n pager.body_text()\n}\n\n#[test]\nfn detail_target_prefers_visible_tool_card() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::User {\n content: \"hello\".to_string(),\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"file_search\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"query: foo\".to_string()),\n output: Some(\"done\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n HistoryCell::Assistant {\n content: \"ok\".to_string(),\n streaming: false,\n },\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"command: ls\".to_string()),\n output: Some(\"...\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n ];\n app.tool_details_by_cell.insert(\n 1,\n ToolDetailRecord {\n tool_id: \"search-1\".to_string(),\n tool_name: \"file_search\".to_string(),\n input: serde_json::json!({\"query\": \"foo\"}),\n output: Some(\"done\".to_string()),\n },\n );\n app.tool_details_by_cell.insert(\n 3,\n ToolDetailRecord {\n tool_id: \"exec-1\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n input: serde_json::json!({\"command\": \"ls\"}),\n output: Some(\"...\".to_string()),\n },\n );\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 1);\n app.viewport.last_transcript_visible = 6;\n\n assert_eq!(detail_target_cell_index(&app), Some(1));\n let expected = format!(\n \"{} Activity: file_search · {} raw\",\n crate::tui::key_shortcuts::activity_shortcut_label(),\n crate::tui::key_shortcuts::tool_details_shortcut_label()\n );\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(expected.as_str())\n );\n}\n\n#[test]\nfn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Thinking {\n content: \"visible reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.4),\n }];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = first_line_for_cell(&app, 0);\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(\n selected_detail_footer_label(&app).as_deref(),\n Some(\"Ctrl+O Activity: thinking\")\n );\n}\n\n#[test]\nfn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {\n let option_v = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::NONE);\n assert!(crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, true));\n assert!(\n !crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&option_v, false)\n );\n\n let modified = KeyEvent::new(KeyCode::Char('\\u{221A}'), KeyModifiers::SHIFT);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&modified, true));\n\n let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);\n assert!(!crate::tui::key_shortcuts::is_macos_option_v_legacy_key_for_platform(&plain_v, true));\n}\n\n#[test]\nfn open_tool_details_pager_supports_active_virtual_tool_cell() {\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"active-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n let active_entries = app\n .active_cell\n .as_ref()\n .expect(\"active cell\")\n .entries()\n .to_vec();\n app.viewport.transcript_cache.ensure_split(\n &[&app.history, active_entries.as_slice()],\n &[1],\n 100,\n app.transcript_render_options(),\n );\n app.viewport.last_transcript_top = 0;\n app.viewport.last_transcript_visible = 4;\n\n assert_eq!(detail_target_cell_index(&app), Some(0));\n assert!(open_tool_details_pager(&mut app));\n assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Pager));\n}\n\n#[test]\nfn spillover_pager_section_returns_none_when_no_spillover() {\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"hi\".to_string()),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n assert!(spillover_pager_section(&app, 0).is_none());\n}\n\n#[test]\nfn spillover_pager_section_loads_file_when_present() {\n use std::io::Write;\n let dir = tempfile::tempdir().unwrap();\n let path = dir.path().join(\"call-test.txt\");\n let mut f = std::fs::File::create(&path).unwrap();\n writeln!(f, \"FULL_OUTPUT_BYTES_HERE\").unwrap();\n\n let mut app = create_test_app();\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(path.clone()),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"section present\");\n assert!(section.contains(\"Full output (spillover)\"));\n assert!(\n section.contains(\"FULL_OUTPUT_BYTES_HERE\"),\n \"section missing file body: {section}\"\n );\n assert!(section.contains(&path.display().to_string()));\n}\n\n#[test]\nfn spillover_pager_section_returns_notice_when_file_missing() {\n let mut app = create_test_app();\n let bogus = std::path::PathBuf::from(\"/tmp/this/path/does/not/exist-spill.txt\");\n app.history = vec![HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"exec_shell\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\"(truncated head)\".to_string()),\n prompts: None,\n spillover_path: Some(bogus),\n output_summary: None,\n is_diff: false,\n }))];\n app.resync_history_revisions();\n\n let section = spillover_pager_section(&app, 0).expect(\"still emits a notice section\");\n assert!(section.contains(\"could not read spillover file\"));\n}\n\n#[test]\nfn terminal_pause_has_live_owner_only_for_running_exec_cells() {\n let mut app = create_test_app();\n assert!(!terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-1\",\n HistoryCell::Tool(ToolCell::Exec(ExecCell {\n command: \"python3 -i\".to_string(),\n status: ToolStatus::Running,\n output: None,\n started_at: Some(Instant::now()),\n duration_ms: None,\n source: ExecSource::Assistant,\n interaction: Some(\"interactive\".to_string()),\n output_summary: None,\n })),\n );\n app.active_cell = Some(active);\n assert!(terminal_pause_has_live_owner(&app));\n\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-2\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n assert!(\n !terminal_pause_has_live_owner(&app),\n \"non-interactive RLM work must not keep the terminal in host-scrollback mode\"\n );\n}\n\n#[test]\nfn active_rlm_task_entries_surface_foreground_rlm_work() {\n let mut app = create_test_app();\n app.turn_started_at = Some(Instant::now() - Duration::from_secs(3));\n let mut active = ActiveCell::new();\n active.push_tool(\n \"tool-rlm\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"rlm\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"file_path: Cargo.lock\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n\n let entries = active_rlm_task_entries(&app);\n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].id, \"rlm-1\");\n assert_eq!(entries[0].status, \"running\");\n assert_eq!(entries[0].prompt_summary, \"RLM: file_path: Cargo.lock\");\n assert!(entries[0].duration_ms.unwrap_or_default() >= 3000);\n}\n\n#[test]\nfn alt_nav_modifiers_require_alt_and_exclude_ctrl_super() {\n // v0.8.30 — transcript-nav shortcuts (`Alt+G`, `Alt+[`, etc.) require\n // Alt, allow Shift for capital-letter forms, and block Ctrl/Super so\n // they don't collide with clipboard / window shortcuts. Bare and\n // Shift-only modifiers fall through to text insertion now.\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::NONE\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::SHIFT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT\n ));\n assert!(crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SHIFT\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::CONTROL\n ));\n assert!(!crate::tui::key_shortcuts::alt_nav_modifiers(\n KeyModifiers::ALT | KeyModifiers::SUPER\n ));\n}\n\n#[test]\nfn ctrl_h_is_treated_as_terminal_backspace() {\n assert!(crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)\n ));\n assert!(!crate::tui::key_shortcuts::is_ctrl_h_backspace(\n &KeyEvent::new(\n KeyCode::Char('h'),\n KeyModifiers::CONTROL | KeyModifiers::ALT\n )\n ));\n}\n\n#[test]\nfn partial_file_mention_finds_token_under_cursor() {\n // Cursor in middle of `@docs/de` should be detected as a partial mention.\n let input = \"look at @docs/de please\";\n let cursor = \"look at @docs/de\".chars().count();\n let (start, partial) = partial_file_mention_at_cursor(input, cursor)\n .expect(\"cursor inside mention should yield a partial\");\n assert_eq!(start, \"look at \".len(), \"byte_start of @ in input\");\n assert_eq!(partial, \"docs/de\");\n}\n\n#[test]\nfn partial_file_mention_returns_none_when_cursor_outside() {\n let input = \"look at @docs/de please\";\n // Cursor after \"please\" — past the whitespace following the mention.\n let cursor = input.chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n\n // Cursor before the `@` — not inside any mention either.\n let early_cursor = \"look\".chars().count();\n assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());\n}\n\n#[test]\nfn partial_file_mention_handles_email_addresses() {\n // The `@` in `user@example.com` is preceded by a non-boundary char so\n // it's not treated as a file-mention.\n let input = \"ping user@example.com now\";\n let cursor = \"ping user@example.com\".chars().count();\n assert!(partial_file_mention_at_cursor(input, cursor).is_none());\n}\n\n#[test]\nfn file_mention_completion_finds_unique_match() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"readme\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"docs/de\", 16);\n assert_eq!(matches, vec![\"docs/deepseek_v4.pdf\".to_string()]);\n}\n\n#[test]\nfn file_mention_completion_ranks_prefix_before_substring() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n std::fs::create_dir_all(tmpdir.path().join(\"nested\")).unwrap();\n std::fs::write(tmpdir.path().join(\"nested/README.md\"), \"x\").unwrap();\n\n let ws = Workspace::with_cwd(tmpdir.path().to_path_buf(), None);\n let matches = find_file_mention_completions(&ws, \"README\", 16);\n // Top-level README (prefix match) outranks the nested one (substring).\n assert_eq!(matches.first().map(String::as_str), Some(\"README.md\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_unique_replaces_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"summarize @docs/de\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"summarize @docs/deepseek_v4.pdf\");\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn try_autocomplete_file_mention_extends_to_common_prefix() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@crates/tui/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n // Both files share the `crates/tui/` prefix and one more letter is\n // not unique (`l` vs `m`), so the partial extends to the common prefix\n // unchanged here, with the status surfacing both candidates.\n assert!(app.input.starts_with(\"@crates/tui/\"));\n let preview = app\n .status_message\n .as_deref()\n .expect(\"status message should describe candidates\");\n assert!(preview.contains(\"@crates/tui/lib.rs\"));\n assert!(preview.contains(\"@crates/tui/main.rs\"));\n}\n\n#[test]\nfn try_autocomplete_file_mention_no_match_reports_status() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@nonexistent_xyz\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(try_autocomplete_file_mention(&mut app));\n assert_eq!(app.input, \"@nonexistent_xyz\");\n assert_eq!(\n app.status_message.as_deref(),\n Some(\"No files match @nonexistent_xyz\")\n );\n}\n\n#[test]\nfn try_autocomplete_file_mention_returns_false_outside_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(!try_autocomplete_file_mention(&mut app));\n}\n\n// ---- P2.1: @-mention popup helpers ----\n//\n// `visible_mention_menu_entries` is the entries source the composer widget\n// renders; `apply_mention_menu_selection` is what Tab/Enter invoke when the\n// popup is open. The popup widget itself piggybacks the slash-menu render\n// path (see `ComposerWidget::active_menu_entries`).\n\n#[test]\nfn mention_popup_is_empty_when_cursor_is_not_in_a_mention() {\n let mut app = create_test_app();\n app.input = \"no mention here\".to_string();\n app.cursor_position = app.input.chars().count();\n assert!(visible_mention_menu_entries(&mut app, 6).is_empty());\n}\n\n#[test]\nfn mention_popup_lists_workspace_matches_for_cursor_partial() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/deepseek_v4.pdf\"), b\"%PDF-\").unwrap();\n std::fs::write(tmpdir.path().join(\"docs/MCP.md\"), \"x\").unwrap();\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"popup should surface docs/ entries\");\n assert!(entries.iter().any(|e| e.starts_with(\"docs/\")));\n // README.md doesn't match `docs/` — confirm we didn't dump every file.\n assert!(!entries.iter().any(|e| e == \"README.md\"));\n}\n\n#[test]\nfn mention_popup_reuses_cache_when_cursor_moves_inside_same_token() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"docs\")).unwrap();\n std::fs::write(tmpdir.path().join(\"docs/alpha.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"look at @docs/\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(entries.iter().any(|e| e == \"docs/alpha.md\"));\n\n std::fs::write(tmpdir.path().join(\"docs/beta.md\"), \"x\").unwrap();\n app.cursor_position = \"look at @do\".chars().count();\n\n let entries_after_cursor_move = visible_mention_menu_entries(&mut app, 6);\n assert_eq!(\n entries_after_cursor_move, entries,\n \"cursor movement inside one @mention token should not re-walk the workspace\",\n );\n\n app.input = \"look at @docs/b\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries_after_partial_change = visible_mention_menu_entries(&mut app, 6);\n assert!(\n entries_after_partial_change\n .iter()\n .any(|e| e == \"docs/beta.md\"),\n \"changing the partial should invalidate the completion cache\",\n );\n}\n\n#[test]\nfn mention_popup_respects_hidden_flag() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"README.md\"), \"x\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"@READ\".to_string();\n app.cursor_position = app.input.chars().count();\n app.mention_menu_hidden = true;\n\n assert!(\n visible_mention_menu_entries(&mut app, 6).is_empty(),\n \"Esc-hidden popup must not surface entries until next input edit\",\n );\n}\n\n#[test]\nfn apply_mention_menu_selection_splices_selected_entry() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::create_dir_all(tmpdir.path().join(\"crates/tui\")).unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/lib.rs\"), \"//\").unwrap();\n std::fs::write(tmpdir.path().join(\"crates/tui/main.rs\"), \"//\").unwrap();\n\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"open @crates/tui/m\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let entries = visible_mention_menu_entries(&mut app, 6);\n assert!(!entries.is_empty(), \"expected entries for @crates/tui/m\");\n // Pick whichever entry appears at index 0; it's deterministic given the\n // workspace setup. Apply it.\n app.mention_menu_selected = 0;\n let applied = apply_mention_menu_selection(&mut app, &entries);\n assert!(\n applied,\n \"apply_mention_menu_selection should report success\"\n );\n assert!(\n app.input.starts_with(\"open @\"),\n \"input should still start with `open @`, got: {input}\",\n input = app.input,\n );\n // Cursor should land at the end of the spliced token.\n assert_eq!(app.cursor_position, app.input.chars().count());\n}\n\n#[test]\nfn apply_mention_menu_selection_is_noop_outside_a_mention() {\n let mut app = create_test_app();\n app.input = \"no @ here\".to_string();\n app.cursor_position = 1; // before the @ token\n let applied = apply_mention_menu_selection(&mut app, &[\"whatever\".to_string()]);\n assert!(!applied);\n assert_eq!(app.input, \"no @ here\");\n}\n\n#[test]\nfn apply_mention_menu_selection_with_no_entries_is_noop() {\n let mut app = create_test_app();\n app.input = \"@partial\".to_string();\n app.cursor_position = app.input.chars().count();\n let applied = apply_mention_menu_selection(&mut app, &[]);\n assert!(!applied);\n}\n\n// === CX#7 — single active cell mutated in place for parallel tool calls ===\n\n/// Build a minimal successful ToolResult with the given content.\nfn ok_result(\n content: &str,\n) -> Result {\n Ok(crate::tools::spec::ToolResult::success(content))\n}\n\n#[test]\nfn tool_child_usage_metadata_updates_live_cost_counter() {\n let mut app = create_test_app();\n let result = Ok(crate::tools::spec::ToolResult::success(\"ok\").with_metadata(\n serde_json::json!({\n \"child_model\": \"deepseek-v4-flash\",\n \"child_input_tokens\": 10_000,\n \"child_output_tokens\": 1_000,\n \"child_prompt_cache_hit_tokens\": 7_000,\n \"child_prompt_cache_miss_tokens\": 3_000,\n }),\n ));\n\n handle_tool_call_complete(&mut app, \"review-usage\", \"review\", &result);\n\n assert!(app.session.subagent_cost > 0.0);\n}\n\n#[test]\nfn spilled_tool_completion_records_session_artifact_metadata() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let spillover_path = tmp.path().join(\"call-big.txt\");\n let raw = \"checking crate ... error[E0425]: cannot find value\\n\".repeat(20);\n std::fs::write(&spillover_path, &raw).expect(\"write spillover\");\n let result = Ok(\n crate::tools::spec::ToolResult::success(\"checking crate ...\").with_metadata(\n serde_json::json!({\n \"spillover_path\": spillover_path.display().to_string(),\n \"artifact_session_id\": \"session-123\",\n \"artifact_relative_path\": \"artifacts/art_call-big.txt\",\n \"artifact_byte_size\": raw.len() as u64,\n \"artifact_preview\": \"checking crate ... error[E0425]: cannot find value\",\n }),\n ),\n );\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n\n handle_tool_call_complete(&mut app, \"call-big\", \"exec_shell\", &result);\n\n assert_eq!(app.session_artifacts.len(), 1);\n let artifact = &app.session_artifacts[0];\n assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput);\n assert_eq!(artifact.session_id, \"session-123\");\n assert_eq!(artifact.tool_call_id, \"call-big\");\n assert_eq!(artifact.tool_name, \"exec_shell\");\n assert_eq!(artifact.byte_size, raw.len() as u64);\n assert_eq!(\n artifact.storage_path,\n PathBuf::from(\"artifacts/art_call-big.txt\")\n );\n assert!(artifact.preview.starts_with(\"checking crate\"));\n\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let snapshot = build_session_snapshot(&app, &manager);\n assert_eq!(snapshot.artifacts, app.session_artifacts);\n}\n\n#[test]\nfn first_snapshot_preserves_current_session_id_for_artifact_ownership() {\n let tmp = tempfile::tempdir().expect(\"tempdir\");\n let manager =\n crate::session_manager::SessionManager::new(tmp.path().join(\"sessions\")).expect(\"manager\");\n let mut app = create_test_app();\n app.current_session_id = Some(\"session-123\".to_string());\n app.api_messages.push(text_message(\"user\", \"hello\"));\n\n let snapshot = build_session_snapshot(&app, &manager);\n\n assert_eq!(snapshot.metadata.id, \"session-123\");\n}\n\n#[test]\nfn apply_loaded_session_restores_artifact_registry() {\n let mut app = create_test_app();\n let mut session = saved_session_with_messages(vec![\n text_message(\"user\", \"hello\"),\n text_message(\"assistant\", \"hi\"),\n ]);\n session.artifacts.push(crate::artifacts::ArtifactRecord {\n id: \"art_call_big\".to_string(),\n kind: crate::artifacts::ArtifactKind::ToolOutput,\n session_id: \"session-123\".to_string(),\n tool_call_id: \"call-big\".to_string(),\n tool_name: \"exec_shell\".to_string(),\n created_at: chrono::Utc::now(),\n byte_size: 128,\n preview: \"hello\".to_string(),\n storage_path: PathBuf::from(\"/tmp/tool_outputs/call-big.txt\"),\n });\n\n let recovered = apply_loaded_session(&mut app, &Config::default(), &session);\n\n assert!(!recovered);\n assert_eq!(app.session_artifacts, session.artifacts);\n}\n\n#[test]\nfn parallel_exploring_tool_starts_share_one_active_entry() {\n // Three exploring tools start in any order; they must collapse into one\n // entry inside the active cell rather than three separate cells. This is\n // the central CX#7 contract for the most common parallel case.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-a\",\n \"read_file\",\n &serde_json::json!({\"path\": \"alpha.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-b\",\n \"read_file\",\n &serde_json::json!({\"path\": \"beta.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-c\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TODO\"}),\n );\n\n // History must remain empty: nothing flushes until the turn ends.\n assert_eq!(app.history.len(), 0, \"no history cells written mid-turn\");\n let active = app.active_cell.as_ref().expect(\"active cell created\");\n assert_eq!(\n active.entry_count(),\n 1,\n \"all exploring starts share one entry\"\n );\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert_eq!(explore.entries.len(), 3);\n for entry in &explore.entries {\n assert_eq!(entry.status, ToolStatus::Running);\n }\n}\n\n#[test]\nfn out_of_order_completes_finalize_one_history_cell_per_turn() {\n // Three parallel tools complete in reverse order; we then signal turn\n // complete and assert exactly one tool history cell exists (the\n // finalized active group). This proves the active cell didn't bounce\n // mid-turn and that the flush path correctly migrates entries.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"read_file\",\n &serde_json::json!({\"path\": \"b.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-3\",\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"x\"}),\n );\n\n // Out-of-order completion: t-3, then t-1, then t-2.\n handle_tool_call_complete(&mut app, \"t-3\", \"grep_files\", &ok_result(\"two hits\"));\n handle_tool_call_complete(&mut app, \"t-1\", \"read_file\", &ok_result(\"contents A\"));\n handle_tool_call_complete(&mut app, \"t-2\", \"read_file\", &ok_result(\"contents B\"));\n\n // Still nothing in history: the active cell holds everything.\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell still present\");\n let HistoryCell::Tool(ToolCell::Exploring(explore)) = &active.entries()[0] else {\n panic!(\"expected exploring cell\")\n };\n assert!(\n explore\n .entries\n .iter()\n .all(|e| e.status == ToolStatus::Success),\n \"all exploring entries should be Success after their tools complete\"\n );\n\n // Flush via the explicit helper (mirrors what TurnComplete does).\n app.flush_active_cell();\n\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n // The flushed group is exactly one history cell — the merged exploring\n // aggregate. This is the heart of CX#7: parallel work renders as ONE\n // finalized cell, regardless of completion order.\n let tool_cells = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .count();\n assert_eq!(\n tool_cells, 1,\n \"exactly one tool history cell after parallel turn\"\n );\n}\n\n#[test]\nfn mixed_parallel_tools_render_in_single_active_cell() {\n // Tools of different shapes — exploring + exec + generic — all in flight\n // at once. The active cell must hold them all without bouncing.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"x.rs\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"gen-1\",\n \"todo_write\",\n &serde_json::json!({\"items\": []}),\n );\n\n assert_eq!(app.history.len(), 0);\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n // 3 entries: exploring aggregate (1) + exec + generic.\n assert_eq!(active.entry_count(), 3);\n\n handle_tool_call_complete(&mut app, \"shell-1\", \"exec_shell\", &ok_result(\"ok\"));\n handle_tool_call_complete(&mut app, \"gen-1\", \"todo_write\", &ok_result(\"done\"));\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"file body\"));\n\n // After all complete, still in active until flush.\n assert_eq!(app.history.len(), 0);\n app.flush_active_cell();\n let tool_cells: Vec<_> = app\n .history\n .iter()\n .filter(|c| matches!(c, HistoryCell::Tool(_)))\n .collect();\n assert_eq!(\n tool_cells.len(),\n 3,\n \"three distinct tool shapes finalize as three cells in stable insertion order\"\n );\n}\n\n#[test]\nfn orphan_tool_complete_with_unknown_id_pushes_separate_cell() {\n // A ToolCallComplete with no matching ToolCallStarted — the orphan path.\n // Per the design we render it as a finalized standalone cell so the user\n // still sees the output, but we must NOT flush or contaminate any active\n // cell that's currently in flight.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"live-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"live.rs\"}),\n );\n\n // Orphan completion arrives.\n handle_tool_call_complete(&mut app, \"ghost-id\", \"mystery_tool\", &ok_result(\"oops\"));\n\n // Active cell is intact.\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell preserved after orphan\");\n assert_eq!(active.entry_count(), 1);\n\n // The orphan rendered as a separate finalized cell pushed to history.\n assert_eq!(app.history.len(), 1, \"orphan added one finalized cell\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"orphan should render as a Generic tool cell\")\n };\n assert_eq!(generic.name, \"mystery_tool\");\n assert_eq!(generic.status, ToolStatus::Success);\n}\n\n#[test]\nfn turn_complete_flushes_active_cell_into_history() {\n // The full path through the public flush helper. Verifies that a\n // mid-turn snapshot (exec running, exploring complete) becomes a stable\n // history slice on flush.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"ex-1\",\n \"read_file\",\n &serde_json::json!({\"path\": \"a.rs\"}),\n );\n handle_tool_call_complete(&mut app, \"ex-1\", \"read_file\", &ok_result(\"body\"));\n handle_tool_call_started(\n &mut app,\n \"shell-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Don't complete shell-1 — simulate cancellation mid-shell.\n app.finalize_active_cell_as_interrupted();\n\n assert!(app.active_cell.is_none(), \"active cell cleared on flush\");\n let exec_cells: Vec<_> = app\n .history\n .iter()\n .filter_map(|c| match c {\n HistoryCell::Tool(ToolCell::Exec(exec)) => Some(exec),\n _ => None,\n })\n .collect();\n assert_eq!(exec_cells.len(), 1);\n assert_eq!(\n exec_cells[0].status,\n ToolStatus::Failed,\n \"interrupted shell entry marked Failed (closest available terminal status)\"\n );\n}\n\n#[test]\nfn orphan_during_active_keeps_subsequent_completion_routed_correctly() {\n // Regression cover for the index-shift trap: when an orphan arrives\n // mid-active, it pushes a real history cell that bumps virtual indices\n // by one. A subsequent legitimate completion must still find its entry.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"live\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n // Orphan completion arrives FIRST (before live's completion).\n handle_tool_call_complete(&mut app, \"ghost\", \"weird_tool\", &ok_result(\"ghost-out\"));\n // Now complete the live tool — it should still mutate the active entry,\n // not silently drop or hit a stale index.\n handle_tool_call_complete(&mut app, \"live\", \"exec_shell\", &ok_result(\"hello\"));\n\n // Active cell still present (turn hasn't completed).\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Exec(exec)) = &active.entries()[0] else {\n panic!(\"expected exec cell\")\n };\n assert_eq!(exec.status, ToolStatus::Success);\n\n // History contains exactly the orphan.\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &app.history[0] else {\n panic!(\"expected orphan generic cell\")\n };\n assert_eq!(generic.name, \"weird_tool\");\n\n // Flush settles the active exec into history below the orphan.\n app.flush_active_cell();\n assert_eq!(app.history.len(), 2);\n}\n\n#[test]\nfn tool_details_survive_active_cell_flush() {\n // Detail pagers resolve tool details by cell index. Flushing the\n // active cell must move detail records into `tool_details_by_cell` so\n // the pager keeps working after the turn settles.\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"echo hi\"}),\n );\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"hi\"));\n app.flush_active_cell();\n\n // The exec cell is now at index 0 in history.\n assert_eq!(app.history.len(), 1);\n let detail = app\n .tool_details_by_cell\n .get(&0)\n .expect(\"detail record migrated to flushed cell index\");\n assert_eq!(detail.tool_id, \"tid\");\n assert_eq!(detail.tool_name, \"exec_shell\");\n}\n\n// ---- exploring labels: codex-style progressive verbs ----\n//\n// Bare names like \"Read foo.rs\" / \"Search pattern\" read as past tense, which\n// is wrong while the tool is still running. Progressive forms (\"Reading…\",\n// \"Searching for…\") match what the user actually sees: a live in-flight\n// action.\n\n#[test]\nfn exploring_label_uses_progressive_for_read_file() {\n let label = exploring_label(\"read_file\", &serde_json::json!({\"path\": \"src/foo.rs\"}));\n assert_eq!(label, \"Reading src/foo.rs\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({\"path\": \"crates/tui/src/\"}));\n assert_eq!(label, \"Listing crates/tui/src/\");\n}\n\n#[test]\nfn exploring_label_uses_progressive_for_list_dir_no_path() {\n let label = exploring_label(\"list_dir\", &serde_json::json!({}));\n assert_eq!(label, \"Listing directory\");\n}\n\n#[test]\nfn exploring_label_for_grep_quotes_pattern_with_searching_for() {\n let label = exploring_label(\n \"grep_files\",\n &serde_json::json!({\"pattern\": \"TranscriptScroll\"}),\n );\n assert_eq!(label, \"Searching for `TranscriptScroll`\");\n}\n\n#[test]\nfn exploring_label_for_list_files_uses_progressive() {\n let label = exploring_label(\"list_files\", &serde_json::json!({}));\n assert_eq!(label, \"Listing files\");\n}\n\n// `running_status_label_with_elapsed` lives in `crate::tui::history` next to\n// the other tool-header helpers — its tests live there too.\n\n// ---- P2.4: auto-scroll churn regressions ----\n//\n// The contract: once the user scrolls away from the live tail mid-turn\n// (`user_scrolled_during_stream = true`), no path should yank them back to\n// the bottom until either (a) they explicitly scroll to tail, (b) the turn\n// ends, or (c) they hit an explicit jump-to-bottom key. Tool-cell handlers\n// only call `mark_history_updated`, which does NOT scroll. `add_message`\n// gates on the flag.\n\n#[test]\nfn add_message_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n // Pre-condition: user was following the tail, then scrolled up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"add_message must respect user_scrolled_during_stream\",\n );\n}\n\n#[test]\nfn add_message_pins_to_tail_when_user_was_following() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::to_bottom();\n app.user_scrolled_during_stream = false;\n\n app.add_message(HistoryCell::User {\n content: \"fresh user message\".to_string(),\n });\n\n assert!(\n app.viewport.transcript_scroll.is_at_tail(),\n \"auto-pin should still work when the user hasn't opted out\",\n );\n}\n\n#[test]\nfn tool_call_started_does_not_scroll_when_user_scrolled_away() {\n // Tool-cell handlers must not sneak in a scroll_to_bottom — they go\n // through `mark_history_updated` which only bumps `history_version`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell start must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn tool_call_complete_does_not_scroll_when_user_scrolled_away() {\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n handle_tool_call_started(\n &mut app,\n \"tid\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // After start, user scrolls up.\n app.viewport.transcript_scroll = TranscriptScroll::at_line(7);\n app.user_scrolled_during_stream = true;\n\n handle_tool_call_complete(&mut app, \"tid\", \"exec_shell\", &ok_result(\"output\"));\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"tool-cell complete must not yank scroll position to bottom\",\n );\n}\n\n#[test]\nfn mark_history_updated_does_not_call_scroll_to_bottom() {\n // Behavior pin: future contributors must not add a scroll_to_bottom\n // here. The scroll-following logic lives only in `add_message` and\n // `flush_active_cell`, both gated on `user_scrolled_during_stream`.\n use crate::tui::scrolling::TranscriptScroll;\n\n let mut app = create_test_app();\n app.viewport.transcript_scroll = TranscriptScroll::at_line(3);\n app.user_scrolled_during_stream = true;\n\n app.mark_history_updated();\n\n assert!(\n !app.viewport.transcript_scroll.is_at_tail(),\n \"mark_history_updated must not scroll\",\n );\n}\n\n// ---- P2.3: thinking + tool calls render as one grouped block ----\n\n#[test]\nfn thinking_then_tools_share_active_cell_until_text_flushes() {\n // Contract: a turn that emits Thinking → Tool → Tool keeps everything\n // inside `active_cell` (one logical \"Working…\" group) until the next\n // assistant prose chunk fires, at which point the group flushes into\n // history in original order.\n let mut app = create_test_app();\n\n // 1. Thinking starts and streams a delta.\n let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, thinking_idx, \"planning the read\");\n assert!(\n app.history.is_empty(),\n \"thinking must not write into history mid-turn\"\n );\n assert_eq!(thinking_idx, 0);\n\n // 2. Two tool calls land in the same active cell.\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n handle_tool_call_started(\n &mut app,\n \"t-2\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"pwd\"}),\n );\n\n let active = app\n .active_cell\n .as_ref()\n .expect(\"active cell present mid-turn\");\n assert_eq!(\n active.entry_count(),\n 3,\n \"thinking + two exec entries share one active cell\"\n );\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n active.entries()[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n\n // 3. Thinking finalizes — entry stays in active cell, just stops streaming.\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), \"\");\n assert!(finalized, \"finalizer reports it touched the active cell\");\n let HistoryCell::Thinking {\n streaming,\n duration_secs,\n content,\n ..\n } = &app\n .active_cell\n .as_ref()\n .expect(\"active cell still present after thinking complete\")\n .entries()[0]\n else {\n panic!(\"expected thinking entry\")\n };\n assert!(!streaming, \"thinking spinner stops after finalize\");\n assert_eq!(*duration_secs, Some(1.5));\n assert_eq!(content, \"planning the read\");\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared after finalize\"\n );\n\n // 4. Assistant prose arriving (simulated by flush) drains the group into\n // history in original order: Thinking → Tool → Tool.\n app.flush_active_cell();\n assert!(app.active_cell.is_none(), \"active cell cleared after flush\");\n assert_eq!(\n app.history.len(),\n 3,\n \"thinking + both tool entries land in history together\"\n );\n assert!(matches!(app.history[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n app.history[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(\n app.history[2],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n}\n\n#[test]\nfn flush_active_cell_finalizes_unclosed_thinking_block() {\n // Defensive: if the engine fails to emit ThinkingComplete before the\n // assistant text arrives, `flush_active_cell` must still stop the\n // spinner so the migrated history cell isn't perpetually streaming.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"incomplete\");\n\n app.flush_active_cell();\n\n assert_eq!(app.history.len(), 1);\n let HistoryCell::Thinking { streaming, .. } = &app.history[0] else {\n panic!(\"expected thinking history cell\")\n };\n assert!(\n !*streaming,\n \"flush must stop the spinner even without ThinkingComplete\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"stream pointer cleared by flush\"\n );\n}\n\n#[test]\nfn open_thinking_pager_finds_thinking_in_active_cell() {\n // After ThinkingComplete fires, the finalized thinking entry stays in\n // `app.active_cell` with `streaming = false` until the active cell is\n // flushed to history (end-of-turn, or when an assistant text arrives).\n // During that window the transcript still renders the Ctrl+O affordance\n // from `render_thinking`, so the handler must reach across the virtual\n // transcript — not just `app.history` — or the promise is a lie.\n // Regression guard for the v0.8.29 affordance/handler mismatch.\n let mut app = create_test_app();\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"deliberating\");\n let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), \"\");\n assert!(finalized);\n assert!(\n app.history.is_empty(),\n \"thinking entry stays in active_cell until flush\"\n );\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert!(matches!(\n active.entries().first(),\n Some(HistoryCell::Thinking {\n streaming: false,\n ..\n })\n ));\n\n assert!(open_thinking_pager(&mut app));\n assert_eq!(\n app.view_stack.top_kind(),\n Some(ModalKind::Pager),\n \"pager must open for thinking entries still in active_cell\"\n );\n let body = pop_pager_body(&mut app);\n assert!(body.contains(\"Activity: reasoning timeline\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 1 of 1\"), \"{body}\");\n assert!(body.contains(\"deliberating\"), \"{body}\");\n}\n\n#[test]\nfn activity_detail_opens_reasoning_timeline_for_selected_thinking() {\n let mut app = create_test_app();\n app.history = vec![\n HistoryCell::Thinking {\n content: \"first chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(0.8),\n },\n HistoryCell::Assistant {\n content: \"interlude\".to_string(),\n streaming: false,\n },\n HistoryCell::Thinking {\n content: \"second chunk reasoning\".to_string(),\n streaming: false,\n duration_secs: Some(1.1),\n },\n ];\n app.resync_history_revisions();\n let revisions = app.history_revisions.clone();\n app.viewport.transcript_cache.ensure(\n &app.history,\n &revisions,\n 100,\n app.transcript_render_options(),\n );\n let line = first_line_for_cell(&app, 0);\n let point = TranscriptSelectionPoint {\n line_index: line,\n column: 0,\n };\n app.viewport.transcript_selection.anchor = Some(point);\n app.viewport.transcript_selection.head = Some(point);\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(\n body.contains(\"Activity: reasoning timeline\"),\n \"activity label missing: {body}\"\n );\n assert!(\n body.contains(\"Selected chunk: 1 of 2\"),\n \"chunk position missing: {body}\"\n );\n assert!(body.contains(\"Thinking chunk 1 of 2 (selected)\"), \"{body}\");\n assert!(body.contains(\"Thinking chunk 2 of 2\"), \"{body}\");\n assert!(body.contains(\"first chunk reasoning\"), \"body: {body}\");\n assert!(\n body.contains(\"second chunk reasoning\"),\n \"timeline should include the whole session's thinking: {body}\"\n );\n}\n\n#[test]\nfn activity_detail_fallback_prefers_live_activity_context() {\n let mut app = create_test_app();\n let mut active = ActiveCell::new();\n active.push_tool(\n \"active-1\",\n HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"agent_eval\".to_string(),\n status: ToolStatus::Running,\n input_summary: Some(\"agent_id: agent_af58ba3a\".to_string()),\n output: None,\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })),\n );\n app.active_cell = Some(active);\n app.runtime_turn_id = Some(\"turn_live_123456789\".to_string());\n app.runtime_turn_status = Some(\"in_progress\".to_string());\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Turn: turn_live_123456789\"));\n assert!(body.contains(\"Activity: tool agent_eval\"));\n assert!(body.contains(\"Status: running\"));\n assert!(body.contains(\"agent_id: agent_af58ba3a\"));\n}\n\n#[test]\nfn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_dump() {\n let mut app = create_test_app();\n let output = (0..20)\n .map(|idx| format!(\"line {idx}\"))\n .collect::>()\n .join(\"\\n\");\n app.history\n .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {\n name: \"read_file\".to_string(),\n status: ToolStatus::Success,\n input_summary: Some(\"src/large.rs\".to_string()),\n output: Some(output),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n })));\n\n assert!(open_activity_detail_pager(&mut app));\n let body = pop_pager_body(&mut app);\n\n assert!(body.contains(\"Activity: tool read_file\"));\n assert!(body.contains(\"Status: done\"));\n assert!(\n body.contains(\"Alt+V for details\"),\n \"activity detail should stay bounded and point to Alt+V for raw detail: {body}\"\n );\n assert!(\n !body.contains(\"line 10\"),\n \"middle of large raw output should not be dumped into Activity Detail: {body}\"\n );\n}\n\n#[test]\nfn engine_error_finalizes_active_thinking_block() {\n use crate::error_taxonomy::StreamError;\n\n let mut app = create_test_app();\n let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"partial reasoning\");\n\n apply_engine_error_to_app(\n &mut app,\n StreamError::Stall { timeout_secs: 60 }.into_envelope(),\n );\n\n let active = app.active_cell.as_ref().expect(\"active thinking remains\");\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[entry_idx]\n else {\n panic!(\"expected active thinking cell\");\n };\n assert!(!*streaming, \"error path must stop the thinking spinner\");\n assert!(\n content.contains(\"partial reasoning\"),\n \"error path must drain pending thinking tail\"\n );\n assert!(app.streaming_thinking_active_entry.is_none());\n}\n\n#[test]\nfn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {\n // #861 RC3: when the engine bursts events, `MessageComplete` can be\n // dispatched ahead of `ThinkingComplete`. Without the defensive drain,\n // `app.last_reasoning` would be `None` at `last_reasoning.take()` time\n // and the thinking block would be dropped from `api_messages`,\n // causing a DeepSeek HTTP 400 on the next turn (V4 thinking-mode\n // requires `reasoning_content` replay).\n //\n // This test exercises the head-of-handler drain in isolation: with a\n // thinking entry still active and `last_reasoning` empty, the drain\n // must transfer `reasoning_buffer` into `last_reasoning` before the\n // remainder of `MessageComplete` reads it.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n app.thinking_started_at = Some(Instant::now());\n app.streaming_state.start_thinking(0, None);\n app.streaming_state.push_content(0, \"deep reasoning text\");\n let _ = app.streaming_state.commit_text(0);\n app.reasoning_buffer.push_str(\"deep reasoning text\");\n\n assert!(\n app.last_reasoning.is_none(),\n \"precondition: ThinkingComplete has NOT fired\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_some(),\n \"precondition: thinking entry is still active\"\n );\n\n // Mirror the head of `EngineEvent::MessageComplete` — the new defensive\n // drain installed by the #861 RC3 fix.\n if app.streaming_thinking_active_entry.is_some() {\n let _ = crate::tui::streaming_thinking::finalize_current(&mut app);\n crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);\n }\n\n assert!(\n app.last_reasoning\n .as_deref()\n .is_some_and(|s| s.contains(\"deep reasoning text\")),\n \"defensive drain must move reasoning into last_reasoning so the\\\n downstream `last_reasoning.take()` produces a Thinking block\"\n );\n assert!(\n app.streaming_thinking_active_entry.is_none(),\n \"thinking entry must be cleared after the drain\"\n );\n}\n\n#[test]\nfn second_thinking_block_appends_new_entry_in_same_active_cell() {\n // Real V4 turns can emit Thinking → Tool → Thinking → Tool before any\n // prose; the second thinking block should land as a fresh entry inside\n // the SAME active cell rather than flush the first group prematurely.\n let mut app = create_test_app();\n\n let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n crate::tui::streaming_thinking::append(&mut app, 0, \"first plan\");\n let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), \"\");\n\n handle_tool_call_started(\n &mut app,\n \"t-1\",\n \"exec_shell\",\n &serde_json::json!({\"command\": \"ls\"}),\n );\n\n // Second Thinking block.\n let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);\n assert_eq!(\n second_idx, 2,\n \"second thinking entry follows the tool entry\"\n );\n crate::tui::streaming_thinking::append(&mut app, second_idx, \"second plan\");\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n assert_eq!(active.entry_count(), 3);\n assert!(matches!(active.entries()[0], HistoryCell::Thinking { .. }));\n assert!(matches!(\n active.entries()[1],\n HistoryCell::Tool(ToolCell::Exec(_))\n ));\n assert!(matches!(active.entries()[2], HistoryCell::Thinking { .. }));\n assert!(\n app.history.is_empty(),\n \"the group still hasn't flushed — no prose yet\"\n );\n}\n\n#[test]\nfn new_thinking_block_drains_pending_tail_from_previous_block() {\n let mut app = create_test_app();\n\n assert!(!crate::tui::streaming_thinking::start_block(&mut app));\n let first_idx = app\n .streaming_thinking_active_entry\n .expect(\"first thinking entry active\");\n app.reasoning_buffer.push_str(\"first tail\");\n app.streaming_state.push_content(0, \"first tail\");\n\n assert!(crate::tui::streaming_thinking::start_block(&mut app));\n let second_idx = app\n .streaming_thinking_active_entry\n .expect(\"second thinking entry active\");\n\n let active = app.active_cell.as_ref().expect(\"active cell exists\");\n assert_ne!(first_idx, second_idx);\n\n let HistoryCell::Thinking {\n content, streaming, ..\n } = &active.entries()[first_idx]\n else {\n panic!(\"expected first thinking cell\");\n };\n assert!(!*streaming, \"previous thinking block should be finalized\");\n assert!(\n content.contains(\"first tail\"),\n \"pending text must survive a new ThinkingStarted event\"\n );\n\n assert!(matches!(\n active.entries()[second_idx],\n HistoryCell::Thinking {\n streaming: true,\n ..\n }\n ));\n assert_eq!(app.last_reasoning.as_deref(), Some(\"first tail\"));\n}\n\n// ---- per-child prompt wiring ----\n//\n// Generic tool cells default to `prompts: None`. Reserved for any future\n// fan-out tool that wants to surface per-child prompts.\n\n#[test]\nfn non_fanout_tool_does_not_populate_prompts() {\n // Ordinary tools must use the standard `args:` summary rendering path.\n let mut app = create_test_app();\n\n handle_tool_call_started(\n &mut app,\n \"fs-1\",\n \"file_search\",\n &serde_json::json!({ \"query\": \"client.rs\" }),\n );\n\n let active = app.active_cell.as_ref().expect(\"active cell present\");\n let HistoryCell::Tool(ToolCell::Generic(generic)) = &active.entries()[0] else {\n panic!(\"expected GenericToolCell for file_search\");\n };\n\n assert!(\n generic.prompts.is_none(),\n \"non-fan-out tool must not populate prompts\"\n );\n}\n#[test]\nfn noisy_subagent_progress_keeps_existing_objective_summary() {\n let mut app = create_test_app();\n app.agent_progress.insert(\n \"agent_live\".to_string(),\n \"starting: inspect release state\".to_string(),\n );\n\n let display =\n friendly_subagent_progress(&app, \"agent_live\", \"step 1/8: requesting model response\");\n\n assert_eq!(display, \"starting: inspect release state\");\n}\n\n/// Regression for issue #65: `truncate_line_to_width` with a tiny budget\n/// must respect display widths, not codepoint counts. The old branch counted\n/// chars and overran the budget for any double-width grapheme, which\n/// contributed to mid-character sidebar artifacts on resize.\n#[test]\nfn truncate_line_to_width_respects_display_width_for_tiny_budgets() {\n use unicode_width::UnicodeWidthStr;\n\n let trimmed = truncate_line_to_width(\"Agents\", 3);\n assert_eq!(trimmed, \"Age\");\n assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);\n\n let trimmed_cjk = truncate_line_to_width(\"中文测试\", 3);\n assert!(\n UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,\n \"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})\",\n UnicodeWidthStr::width(trimmed_cjk.as_str()),\n );\n\n assert_eq!(truncate_line_to_width(\"anything\", 0), \"\");\n assert_eq!(truncate_line_to_width(\"hi\", 10), \"hi\");\n\n let trimmed_long = truncate_line_to_width(\"a long sidebar label\", 10);\n assert!(trimmed_long.ends_with(\"...\"));\n assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);\n}\n\n/// Regression for #86. A recoverable engine error (stream stall, transient\n/// disconnect, retryable server hiccup) must NOT flip the session into\n/// offline mode. Until this fix the UI matched on `EngineEvent::Error {\n/// message, .. }` and unconditionally set `app.offline_mode = true`, so a\n/// long V4 thinking turn whose chunked stream got closed mid-flight ended\n/// the session in offline mode with the next typed message queued.\n#[test]\nfn recoverable_engine_error_does_not_enter_offline_mode() {\n use crate::error_taxonomy::{ErrorEnvelope, StreamError};\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n let envelope = StreamError::Stall { timeout_secs: 60 }.into_envelope();\n apply_engine_error_to_app(&mut app, envelope);\n\n assert!(\n !app.offline_mode,\n \"recoverable error must keep the session online so the user can retry\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n\n // Sanity: the rendered cell is the categorized Error variant, not a plain System note.\n let last = app\n .history\n .last()\n .expect(\"recoverable engine error should push a history cell\");\n assert!(\n matches!(last, crate::tui::history::HistoryCell::Error { .. }),\n \"expected HistoryCell::Error, got {last:?}\"\n );\n let _ = ErrorEnvelope::transient(\"\");\n}\n\n/// Hard failures (auth, billing, malformed request) DO need to flip offline\n/// mode so subsequent typed messages get queued instead of silently lost\n/// against a broken upstream.\n#[test]\nfn non_recoverable_engine_error_enters_offline_mode() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n assert!(!app.offline_mode);\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(\n app.offline_mode,\n \"non-recoverable error must enter offline mode\"\n );\n assert!(!app.is_loading);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n assert!(\n app.status_message.is_none(),\n \"non-recoverable error should NOT set status_message — already in transcript as HistoryCell::Error\"\n );\n}\n\n#[test]\nfn env_only_auth_failure_reopens_api_key_onboarding() {\n use crate::error_taxonomy::ErrorEnvelope;\n let mut app = create_test_app();\n app.api_key_env_only = true;\n app.onboarding = crate::tui::app::OnboardingState::None;\n app.onboarding_needs_api_key = false;\n\n apply_engine_error_to_app(\n &mut app,\n ErrorEnvelope::fatal_auth(\"Authentication failed: invalid API key\"),\n );\n\n assert!(app.offline_mode);\n assert_eq!(\n app.onboarding,\n crate::tui::app::OnboardingState::ApiKey,\n \"env-only auth failures should prompt for a saved config key\"\n );\n assert!(app.onboarding_needs_api_key);\n assert!(app.turn_error_posted, \"turn_error_posted must be set\");\n let status = app\n .status_message\n .as_deref()\n .expect(\"auth recovery should explain the env key source\");\n assert!(\n status.contains(\"DEEPSEEK_API_KEY\"),\n \"expected env-specific recovery hint, got {status:?}\"\n );\n}\n\n// ---- Issue #208: in-flight input routing ----\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_empty_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_cancels_when_loading_with_input() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"hold on, look at this instead\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_treats_whitespace_only_as_empty() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \" \\n\\t\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest);\n}\n\n#[test]\nfn next_escape_action_idle_with_input_clears() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input = \"draft\".to_string();\n assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput);\n}\n\n#[test]\nfn next_escape_action_idle_empty_is_noop() {\n let mut app = create_test_app();\n app.is_loading = false;\n app.input.clear();\n assert_eq!(next_escape_action(&app, false), EscapeAction::Noop);\n}\n\n#[test]\nfn next_escape_action_slash_menu_takes_priority() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"anything\".to_string();\n assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu);\n}\n\n#[test]\nfn tab_queues_running_turn_draft_for_next_turn() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"follow up next\".to_string();\n app.cursor_position = app.input.chars().count();\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n assert!(app.input.is_empty());\n assert_eq!(app.queued_message_count(), 1);\n assert_eq!(\n app.queued_messages.front().map(|msg| msg.display.as_str()),\n Some(\"follow up next\")\n );\n assert!(\n app.status_message\n .as_deref()\n .is_some_and(|msg| msg.contains(\"queued — ↑\"))\n );\n}\n\n#[test]\nfn tab_queue_preserves_queued_draft_skill_instruction() {\n let mut app = create_test_app();\n app.is_loading = true;\n app.input = \"edited queued follow-up\".to_string();\n app.cursor_position = app.input.chars().count();\n app.queued_draft = Some(QueuedMessage::new(\n \"original\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n\n assert!(queue_current_draft_for_next_turn(&mut app));\n\n let queued = app.queued_messages.front().expect(\"queued message\");\n assert_eq!(queued.display, \"edited queued follow-up\");\n assert_eq!(queued.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.queued_draft.is_none());\n}\n\n#[test]\nfn merge_pending_steers_returns_none_when_empty() {\n let mut app = create_test_app();\n assert!(merge_pending_steers(&mut app).is_none());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_passes_through_single_message() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"lone steer\".to_string(),\n Some(\"skill body\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"lone steer\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"skill body\"));\n assert!(app.pending_steers.is_empty());\n assert!(!app.submit_pending_steers_after_interrupt);\n}\n\n#[test]\nfn merge_pending_steers_concatenates_multiple_with_blank_line() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"first\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"second\".to_string(), None));\n app.push_pending_steer(QueuedMessage::new(\"third\".to_string(), None));\n\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.display, \"first\\n\\nsecond\\n\\nthird\");\n assert!(app.pending_steers.is_empty());\n}\n\n#[test]\nfn merge_pending_steers_keeps_first_skill_instruction_only() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\n \"a\".to_string(),\n Some(\"first skill\".to_string()),\n ));\n app.push_pending_steer(QueuedMessage::new(\n \"b\".to_string(),\n Some(\"second skill\".to_string()),\n ));\n let merged = merge_pending_steers(&mut app).expect(\"merge yields a message\");\n assert_eq!(merged.skill_instruction.as_deref(), Some(\"first skill\"));\n assert_eq!(merged.display, \"a\\n\\nb\");\n}\n\n#[test]\nfn build_pending_input_preview_populates_all_three_buckets() {\n let mut app = create_test_app();\n app.push_pending_steer(QueuedMessage::new(\"steer-msg\".to_string(), None));\n app.rejected_steers.push_back(\"rejected-msg\".to_string());\n app.queue_message(QueuedMessage::new(\"queued-msg\".to_string(), None));\n\n let preview = build_pending_input_preview(&app);\n assert_eq!(preview.pending_steers, vec![\"steer-msg\".to_string()]);\n assert_eq!(preview.rejected_steers, vec![\"rejected-msg\".to_string()]);\n assert_eq!(preview.queued_messages, vec![\"queued-msg\".to_string()]);\n}\n\n#[test]\nfn build_pending_input_preview_includes_current_context_chips() {\n let tmpdir = TempDir::new().expect(\"tempdir\");\n std::fs::write(tmpdir.path().join(\"guide.md\"), \"hello\").expect(\"write\");\n let mut app = create_test_app();\n app.workspace = tmpdir.path().to_path_buf();\n app.input = \"Read @guide.md and @missing.md\".to_string();\n app.cursor_position = app.input.chars().count();\n\n let preview = build_pending_input_preview(&app);\n\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"file\" && item.label == \"guide.md\" && item.included),\n \"file mention preview missing: {:?}\",\n preview.context_items\n );\n assert!(\n preview\n .context_items\n .iter()\n .any(|item| item.kind == \"missing\" && item.label == \"missing.md\" && !item.included),\n \"missing mention preview missing: {:?}\",\n preview.context_items\n );\n}\n\n#[test]\nfn render_footer_from_with_default_items_renders_mode_and_model() {\n // Default footer composition should show the mode chip and model\n // identifier — whatever the configured default model is.\n let mut app = create_test_app();\n app.session.session_cost = 0.00005;\n let items = crate::config::StatusItem::default_footer();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n // Tiny but real costs should render instead of disappearing as \"$0.00\".\n assert!(!props.cost.is_empty());\n assert_eq!(spans_text(&props.cost), \"<$0.0001\");\n}\n\n#[test]\nfn default_footer_includes_prefix_stability_before_cache() {\n let items = crate::config::StatusItem::default_footer();\n let prefix = items\n .iter()\n .position(|item| *item == crate::config::StatusItem::PrefixStability)\n .expect(\"default footer includes prefix stability\");\n let cache = items\n .iter()\n .position(|item| *item == crate::config::StatusItem::Cache)\n .expect(\"default footer includes cache\");\n\n assert!(prefix < cache);\n}\n\n#[test]\nfn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);\n\n assert_eq!(spans_text(&props.cache), \"P 100%\");\n}\n\n#[test]\nfn render_footer_from_preserves_prefix_then_cache_order() {\n let mut app = create_test_app();\n app.prefix_stability_pct = Some(100);\n app.prefix_change_count = 0;\n app.session.last_prompt_tokens = Some(10_000);\n app.session.last_prompt_cache_hit_tokens = Some(9_000);\n app.session.last_prompt_cache_miss_tokens = Some(1_000);\n\n let props = render_footer_from(\n &app,\n &[\n crate::config::StatusItem::PrefixStability,\n crate::config::StatusItem::Cache,\n ],\n None,\n );\n\n assert!(spans_text(&props.cache).starts_with(\"P 100% Cache: 90.0% hit\"));\n}\n\n#[test]\nfn render_footer_from_with_empty_items_blanks_every_segment() {\n // A user who toggles every chip OFF should get a bare footer (no model\n // text, no cost, no auxiliary chips). This is the explicit-empty case.\n let mut app = create_test_app();\n app.session.session_cost = 1.5;\n let props = render_footer_from(&app, &[], None);\n assert_eq!(props.mode_label, \"\");\n assert!(props.model.is_empty());\n assert!(props.cost.is_empty());\n assert!(props.coherence.is_empty());\n assert!(props.agents.is_empty());\n assert!(props.cache.is_empty());\n}\n\n#[test]\nfn render_footer_from_drops_only_unselected_clusters() {\n // Toggling Cost off but keeping the rest should hide cost only.\n let mut app = create_test_app();\n app.session.session_cost = 0.42;\n let items: Vec = crate::config::StatusItem::default_footer()\n .into_iter()\n .filter(|item| *item != crate::config::StatusItem::Cost)\n .collect();\n let props = render_footer_from(&app, &items, None);\n assert_eq!(props.mode_label, \"agent\");\n assert!(!props.model.is_empty(), \"footer should show a model name\");\n assert!(\n props.cost.is_empty(),\n \"cost cluster should be empty when Cost is disabled\"\n );\n}\n\n#[test]\nfn render_footer_from_git_branch_item_renders_workspace_branch() {\n let repo = init_git_repo();\n let checkout = Command::new(\"git\")\n .args([\"checkout\", \"-b\", \"feature/statusline\"])\n .current_dir(repo.path())\n .output()\n .expect(\"git checkout should run\");\n assert!(\n checkout.status.success(),\n \"git checkout failed: {}\",\n String::from_utf8_lossy(&checkout.stderr)\n );\n\n let mut app = create_test_app();\n app.workspace = repo.path().to_path_buf();\n\n let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);\n assert_eq!(spans_text(&props.cache), \"feature/statusline\");\n}\n\n/// Regression for issue #244: visible session spend must not decrease.\n/// Sub-agent token usage events arrive out of order and may be reconciled\n/// later (cache adjustments, provisional → final swap). The displayed total\n/// is anchored to a high-water mark so users never see a number go down\n/// during a single session.\n#[test]\nfn displayed_session_cost_is_monotonic_under_negative_reconciliation() {\n let mut app = create_test_app();\n app.accrue_subagent_cost(0.50);\n let after_first = app.displayed_session_cost();\n assert!((after_first - 0.50).abs() < 1e-6);\n\n // Simulate reconciliation that lowers the underlying counter (e.g. a\n // cache discount applied after the fact). The underlying value drops,\n // but the displayed cost must not.\n app.session.subagent_cost = 0.20;\n let after_recon = app.displayed_session_cost();\n assert!(\n after_recon >= after_first,\n \"displayed cost regressed: {after_recon} < {after_first}\"\n );\n\n // Adding more cost should still bump above the high-water.\n app.accrue_session_cost(0.10);\n let after_add = app.displayed_session_cost();\n assert!(after_add >= after_first);\n}\n\n/// Regression for issue #244: deduplicated mailbox events must not\n/// decrement displayed cost — they should leave it untouched and the\n/// next genuine event must extend it monotonically.\n#[test]\nfn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() {\n let mut app = create_test_app();\n let usage = crate::tools::subagent::MailboxMessage::TokenUsage {\n agent_id: \"agent-x\".to_string(),\n model: \"deepseek-v4-flash\".to_string(),\n usage: crate::models::Usage {\n input_tokens: 10_000,\n output_tokens: 1_000,\n ..Default::default()\n },\n };\n handle_subagent_mailbox(&mut app, 11, &usage);\n let baseline = app.displayed_session_cost();\n assert!(baseline > 0.0);\n\n // Re-emit the same seq — must be deduped, displayed cost unchanged.\n handle_subagent_mailbox(&mut app, 11, &usage);\n assert!(\n (app.displayed_session_cost() - baseline).abs() < 1e-9,\n \"duplicate mailbox seq must not move displayed cost\"\n );\n\n // A fresh seq must extend the displayed cost upward.\n handle_subagent_mailbox(&mut app, 12, &usage);\n assert!(app.displayed_session_cost() > baseline);\n}\n#[test]\nfn checklist_write_renders_dedicated_card() {\n let cell = GenericToolCell {\n name: \"checklist_write\".to_string(),\n status: ToolStatus::Success,\n input_summary: None,\n output: Some(\n \"Todo list updated (3 items, 33% complete)\\n{\\\"items\\\":[{\\\"id\\\":1,\\\"content\\\":\\\"Plan it out\\\",\\\"status\\\":\\\"completed\\\"},{\\\"id\\\":2,\\\"content\\\":\\\"Wire the thing\\\",\\\"status\\\":\\\"in_progress\\\"},{\\\"id\\\":3,\\\"content\\\":\\\"Run gates\\\",\\\"status\\\":\\\"pending\\\"}],\\\"completion_pct\\\":33,\\\"in_progress_id\\\":2}\"\n .to_string(),\n ),\n prompts: None,\n spillover_path: None,\n output_summary: None,\n is_diff: false,\n };\n let lines = cell.lines_with_mode(80, true, crate::tui::history::RenderMode::Live);\n let text: Vec = lines\n .iter()\n .map(|line| {\n line.spans\n .iter()\n .map(|span| span.content.as_ref())\n .collect::()\n })\n .collect();\n let joined = text.join(\"\\n\");\n\n assert!(\n joined.contains(\"1/3\"),\n \"header must include completed/total: {joined}\"\n );\n assert!(\n joined.contains(\"33%\"),\n \"header must include percent: {joined}\"\n );\n assert!(\n joined.contains(\"Plan it out\"),\n \"items must render content: {joined}\"\n );\n assert!(\n !joined.contains(\"\\\"items\\\"\"),\n \"raw JSON must NOT appear: {joined}\"\n );\n}\n\n// ---- composer arrow history ----\n\n#[test]\nfn history_arrow_handles_empty_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input_history.push(\"previous prompt\".to_string());\n\n // With arrows-scroll off: empty composer Up navigates input history (#1117).\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_whitespace_input() {\n let mut app = create_test_app();\n // Explicitly disable arrows-scroll so this test covers the\n // history-navigation path regardless of the mouse-capture default.\n app.composer_arrows_scroll = false;\n app.input = \" \".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn history_arrow_handles_nonempty_input() {\n let mut app = create_test_app();\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n\n assert_eq!(app.input, \"previous prompt\");\n}\n\n#[test]\nfn composer_arrows_scroll_empty_up() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n // Opt-in: empty composer Up scrolls transcript.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, -3);\n assert!(app.input.is_empty());\n}\n\n#[test]\nfn composer_arrows_scroll_empty_down() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.viewport.pending_scroll_delta, 3);\n}\n\n#[test]\nfn composer_arrows_scroll_nonempty_still_navigates_history() {\n let mut app = create_test_app();\n app.composer_arrows_scroll = true;\n app.input = \"hello\".to_string();\n app.cursor_position = app.input.chars().count();\n app.input_history.push(\"previous prompt\".to_string());\n\n // Even with the option on, non-empty composer still navigates history.\n assert!(handle_composer_history_arrow(\n &mut app,\n KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),\n false,\n false,\n ));\n assert_eq!(app.input, \"previous prompt\");\n}\n\n// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll\n// must default to true so mouse-wheel events (sent as arrow keys by\n// the terminal) scroll the transcript rather than cycling history.\n#[test]\nfn composer_arrows_scroll_defaults_true_without_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n app.composer_arrows_scroll,\n \"arrows-scroll must default to true when mouse capture is off\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_defaults_false_with_mouse_capture() {\n let options = TuiOptions {\n use_mouse_capture: true,\n ..create_test_options()\n };\n let app = App::new(options, &Config::default());\n assert!(\n !app.composer_arrows_scroll,\n \"arrows-scroll must default to false when mouse capture is on\"\n );\n}\n\n#[test]\nfn composer_arrows_scroll_config_overrides_default() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n composer_arrows_scroll: Some(false),\n ..Default::default()\n }),\n ..Config::default()\n };\n // Even with mouse_capture off, explicit config=false wins.\n let options = TuiOptions {\n use_mouse_capture: false,\n ..create_test_options()\n };\n let app = App::new(options, &config);\n assert!(\n !app.composer_arrows_scroll,\n \"explicit config=false must override the mouse-capture-derived default\"\n );\n}\n\n#[test]\nfn notification_settings_tui_always_keeps_configured_method_no_threshold() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Always),\n ..Default::default()\n }),\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Bel,\n threshold_secs: 120,\n include_summary: true,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Bel);\n assert_eq!(threshold, Duration::ZERO);\n assert!(include_summary);\n}\n\n#[test]\nfn notification_settings_tui_never_disables_notifications() {\n let config = Config {\n tui: Some(crate::config::TuiConfig {\n notification_condition: Some(crate::config::NotificationCondition::Never),\n ..Default::default()\n }),\n ..Config::default()\n };\n\n assert!(crate::tui::notifications::settings(&config).is_none());\n}\n\n#[test]\nfn notification_settings_no_tui_override_uses_notifications_block() {\n let config = Config {\n notifications: Some(crate::config::NotificationsConfig {\n method: crate::config::NotificationMethod::Osc9,\n threshold_secs: 45,\n include_summary: false,\n }),\n ..Config::default()\n };\n\n let (method, threshold, include_summary) =\n crate::tui::notifications::settings(&config).expect(\"notification should be enabled\");\n assert_eq!(method, crate::tui::notifications::Method::Osc9);\n assert_eq!(threshold, Duration::from_secs(45));\n assert!(!include_summary);\n}\n\n#[test]\nfn completed_turn_notification_uses_streaming_text() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"Hello there.\\n\\nWhat's next?\",\n false,\n Duration::from_secs(12),\n None,\n );\n assert_eq!(msg, \"Hello there.\\nWhat's next?\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_latest_assistant_message() {\n let mut app = create_test_app();\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Earlier turn\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"user\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"next\".to_string(),\n cache_control: None,\n }],\n });\n app.api_messages.push(crate::models::Message {\n role: \"assistant\".to_string(),\n content: vec![crate::models::ContentBlock::Text {\n text: \"Latest reply\".to_string(),\n cache_control: None,\n }],\n });\n\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(75),\n None,\n );\n assert_eq!(msg, \"Latest reply\");\n}\n\n#[test]\nfn completed_turn_notification_falls_back_to_default_when_empty() {\n let app = create_test_app();\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n \"\",\n false,\n Duration::from_secs(5),\n None,\n );\n assert_eq!(msg, \"deepseek: turn complete\");\n}\n\n#[test]\nfn completed_turn_notification_truncates_long_text() {\n let app = create_test_app();\n let long = \"a\".repeat(500);\n let msg = crate::tui::notifications::completed_turn_message(\n &app,\n &long,\n false,\n Duration::from_secs(5),\n None,\n );\n assert!(msg.ends_with(\"...\"));\n // 360-char body + 3-char ellipsis\n assert_eq!(msg.chars().count(), 363);\n}\n\n#[test]\nfn subagent_completion_notification_uses_summary_line_not_sentinel() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"Finished the docs audit.\\n{}\",\n false,\n Duration::from_secs(42),\n );\n\n assert_eq!(msg, \"sub-agent agent_live: Finished the docs audit.\");\n assert!(!msg.contains(\"deepseek:subagent.done\"));\n}\n\n#[test]\nfn subagent_completion_notification_can_include_elapsed_summary() {\n let msg = crate::tui::notifications::subagent_completion_message(\n \"agent_live\",\n \"\",\n true,\n Duration::from_secs(65),\n );\n\n assert!(msg.contains(\"deepseek: sub-agent agent_live complete\"));\n assert!(msg.contains(\"deepseek: sub-agent complete (1m 5s)\"));\n}\n\n#[test]\nfn sanitize_stream_chunk_keeps_printable_and_drops_control_bytes() {\n // `sanitize_stream_chunk` is the per-chunk filter every piece of\n // streaming text goes through (assistant content, thinking\n // content, tool results, web-search snippets). Pin both\n // invariants:\n //\n // 1. preserve user-visible whitespace (newline / tab) — collapsing\n // those would mangle code blocks and tool output;\n // 2. drop terminal-escape-friendly control bytes — a chunk\n // containing `\\u{1b}[2J` (clear screen) or `\\u{8}` (backspace)\n // must not reach the renderer.\n let cleaned = super::sanitize_stream_chunk(\"hello\\tworld\\n\");\n assert_eq!(cleaned, \"hello\\tworld\\n\", \"tabs and newlines must survive\");\n\n // ESC + CSI sequence: only the printable letters/digits survive.\n let cleaned = super::sanitize_stream_chunk(\"text\\u{1b}[2Jmore\");\n assert_eq!(cleaned, \"text[2Jmore\", \"ESC byte must be filtered\");\n\n // Bell, backspace, vertical tab, form feed — all are control\n // characters that aren't `\\n` or `\\t`. Drop them.\n let cleaned = super::sanitize_stream_chunk(\"a\\u{7}b\\u{8}c\\u{b}d\\u{c}e\");\n assert_eq!(cleaned, \"abcde\");\n\n // Carriage return is also a control char; today's renderer expects\n // unix newlines, so CR is filtered out. Pin so a future CRLF-mode\n // change has to update this test intentionally.\n let cleaned = super::sanitize_stream_chunk(\"line1\\r\\nline2\");\n assert_eq!(cleaned, \"line1\\nline2\");\n}\n\n#[test]\nfn sanitize_stream_chunk_preserves_unicode() {\n // Non-ASCII Unicode is not control — CJK, emoji, accented Latin\n // all pass through untouched.\n let cjk = \"\\u{4f60}\\u{597d}\\u{ff0c}DeepSeek\";\n assert_eq!(super::sanitize_stream_chunk(cjk), cjk);\n\n let emoji_and_accents = \"caf\\u{e9} \\u{1f680} build\";\n assert_eq!(\n super::sanitize_stream_chunk(emoji_and_accents),\n emoji_and_accents,\n );\n}\n\n#[test]\nfn sanitize_stream_chunk_handles_empty_and_whitespace() {\n assert_eq!(super::sanitize_stream_chunk(\"\"), \"\");\n assert_eq!(super::sanitize_stream_chunk(\" \"), \" \");\n // A chunk that's purely control bytes shrinks to empty — caller\n // branches that skip empty chunks handle the result, so the\n // filter doesn't need to inject a placeholder.\n assert_eq!(super::sanitize_stream_chunk(\"\\u{1b}\\u{7}\\u{8}\"), \"\");\n}\n\n#[test]\nfn toast_stack_overlay_respects_composer_boundary() {\n // Verify that the toast stack area calculation respects the composer area\n // boundary and doesn't overlap. This is a regression test for the issue\n // where deferred tool loading notifications appeared in the composer input.\n //\n // Layout:\n // - Composer area: rows 10-14 (height=5, y=10)\n // - Footer area: rows 15-16 (height=2, y=15)\n // - Available space for toast stack: rows 14-14 (max 1 row above footer)\n let _full_area = ratatui::prelude::Rect {\n x: 0,\n y: 0,\n width: 80,\n height: 16,\n };\n let composer_area = ratatui::prelude::Rect {\n x: 0,\n y: 10,\n width: 80,\n height: 5,\n };\n let footer_area = ratatui::prelude::Rect {\n x: 0,\n y: 15,\n width: 80,\n height: 1,\n };\n\n // With 2 toasts, the stack overlay would try to render 1 toast above footer\n // max_above should be: footer_area.y (15) - composer_area.y.saturating_sub(1) (9)\n // = 15 - 9 = 6 rows available\n // But that's the full space above footer. The real constraint is the gap\n // between composer end and footer start.\n // Composer ends at row 14 (y=10 + height=5 - 1)\n // Footer starts at row 15\n // So only row 14 is available for toasts (1 row)\n\n // The calculation should be:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15.saturating_sub(10 - 1)\n // = 15 - 9 = 6\n // But wait, composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This gives us the space BEFORE the composer starts, which is wrong.\n //\n // The correct logic should be:\n // composer_end = composer_area.y + composer_area.height\n // available = footer_area.y.saturating_sub(composer_end)\n // But we're using: footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // Which is: 15 - 9 = 6, the total height above composer start\n // But we only want the gap between composer end and footer\n //\n // Actually, the formula composer_area.y.saturating_sub(1) means:\n // \"find the row right before the composer starts\"\n // And we subtract that from footer_area.y to get the space between composer and footer.\n // This is correct: footer_area.y - (composer_area.y - 1) - 1 = gap\n // Wait, let me recalculate:\n // Composer area: y=10, height=5 means rows 10-14\n // Footer area: y=15 means row 15\n // Gap = 15 - (10 + 5) = 0 (they're adjacent!)\n //\n // Let me reconsider the formula in the code:\n // max_above = footer_area.y.saturating_sub(composer_area.y.saturating_sub(1))\n // = 15 - (10 - 1)\n // = 15 - 9 = 6\n //\n // But the composer occupies rows 10-14, and footer is at row 15.\n // So there's actually no gap! The calculation gives 6, which includes:\n // - Rows before composer (0-9) = 10 rows\n // - Rows at composer end (14) = 1 row\n // Total = 11 rows, but we get 6... that doesn't match.\n //\n // Actually wait, let me re-read the formula:\n // composer_area.y.saturating_sub(1) = 10 - 1 = 9\n // This is row 9 (the row right before composer starts at row 10)\n // footer_area.y - 9 = 15 - 9 = 6\n // This is the number of rows from row 9 to row 15 (exclusive), which is rows 9-14 = 6 rows\n // This is correct! It's the space from before the composer to the footer.\n //\n // But wait, the composer STARTS at row 10, not row 9.\n // So rows 9-14 includes the composer! That's not right either.\n //\n // I think I'm overcomplicating this. Let me just verify that the calculation\n // doesn't allow the toast to overlap with the composer.\n\n // The actual fix in `render_toast_stack_overlay` computes\n // composer_end = composer_area.y + composer_area.height\n // max_above = footer_area.y.saturating_sub(composer_end)\n // so when composer and footer are adjacent (no gap), max_above\n // collapses to 0 and the overlay is silently skipped rather than\n // rendering on top of the composer's last row.\n let composer_end = composer_area.y + composer_area.height;\n let max_above = footer_area.y.saturating_sub(composer_end);\n\n assert_eq!(\n max_above, 0,\n \"with adjacent composer (rows 10-14) and footer (row 15) there is \\\n no gap, so the toast stack must report zero available rows\"\n );\n // Sanity: the calculated cap must never exceed the gap. This is what\n // prevents the v0.8.31 overlap regression — any positive value here on\n // an adjacent layout would put toast text on top of the composer.\n let gap = footer_area.y.saturating_sub(composer_end);\n assert!(\n max_above <= gap,\n \"max_above ({max_above}) must never exceed the composer→footer gap ({gap})\"\n );\n}\n"}}, "crates/tui/tests/qa_pty.rs": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785745880.6384568, "n": 10521, "h": "be72af9de78e3133", "c": "//! End-to-end TUI scenarios driven through a real pseudo-terminal.\n//!\n//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,\n//! sends scripted input through the PTY, and asserts on the parsed terminal\n//! frame and on the workspace filesystem. See `support/qa_harness/README.md`\n//! for design + how-to.\n//!\n//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,\n//! #765, #802) needs a separate audit before scenarios light up there.\n\n#![cfg(unix)]\n\n#[path = \"support/qa_harness/mod.rs\"]\nmod qa_harness;\n\nuse std::time::Duration;\n\nuse qa_harness::harness::{Harness, make_sealed_workspace};\nuse qa_harness::keys;\n\nconst BOOT_TIMEOUT: Duration = Duration::from_secs(15);\nconst KEY_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n spawn_minimal(ws)\n}\n\nfn boot_minimal_without_retry() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n std::fs::write(\n ws.home().join(\".deepseek\").join(\"config.toml\"),\n \"[retry]\\nenabled = false\\n\",\n )?;\n spawn_minimal(ws)\n}\n\nfn spawn_minimal(\n ws: qa_harness::harness::SealedWorkspace,\n) -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let h = Harness::builder(Harness::cargo_bin(\"codewhale-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n // Provide a stub key so the onboarding screen is bypassed and the TUI\n // boots straight into the composer. The harness never makes a live\n // request — we just need the binary to think a key exists.\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n // Force a known base URL so the doctor / model probe never escapes\n // the box. 127.0.0.1:1 will refuse instantly.\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n Ok((ws, h))\n}\n\nfn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {\n let dir = root.join(name);\n std::fs::create_dir_all(&dir)?;\n std::fs::write(\n dir.join(\"SKILL.md\"),\n format!(\"---\\nname: {name}\\ndescription: {description}\\n---\\nUse {name}.\\n\"),\n )?;\n Ok(())\n}\n\nfn first_non_blank_row(frame: &qa_harness::Frame) -> Option {\n (0..frame.rows()).find(|&row| !frame.row(row).trim().is_empty())\n}\n\nfn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {\n let dump = frame.debug_dump();\n let first_row = first_non_blank_row(frame).expect(\"expected visible frame text\");\n assert_eq!(\n first_row, 0,\n \"viewport content drifted below row 0:\\n{dump}\"\n );\n assert!(\n frame.row(0).contains(\"Plan\")\n || frame.row(0).contains(\"Agent\")\n || frame.row(0).contains(\"Yolo\")\n || frame.row(0).contains(\"DeepSeek\"),\n \"expected header content on row 0:\\n{dump}\"\n );\n}\n\n/// Smoke: the binary boots into an alt-screen, paints a composer, and the\n/// header shows the project label. If this fails, the harness itself is\n/// broken before we worry about any scenario.\n#[test]\nfn smoke_boot_paints_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n\n // The composer panel border is labelled \"Composer\" — wait for it.\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n let f = h.frame();\n assert!(\n f.any_visible_text(),\n \"expected non-empty frame after boot:\\n{}\",\n f.debug_dump()\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression for #1085: after a turn exits through the error path, terminal\n/// origin/scroll-region state must not leave blank rows above the TUI.\n#[test]\nfn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal_without_retry()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n assert_viewport_starts_at_top(h.frame());\n\n h.send(keys::key::text(\"trigger a failed turn\"))?;\n h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for(\n |frame| {\n frame.contains(\"Turn failed\")\n || frame.contains(\"Connection refused\")\n || frame.contains(\"error\")\n },\n Duration::from_secs(15),\n )?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n assert_viewport_starts_at_top(h.frame());\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Verifies the harness actually sees keystrokes — type a character and watch\n/// it appear in the composer. This is the lowest-effort sanity check before\n/// we lean on it for real scenarios.\n#[test]\nfn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n h.send(keys::key::text(\"hello-from-pty\"))?;\n h.wait_for_text(\"hello-from-pty\", KEY_TIMEOUT)?;\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression: `/skills` should reflect the same merged discovery set as the\n/// slash menu and model-visible skills block, not just the first selected\n/// skills directory.\n#[test]\nfn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {\n let ws = make_sealed_workspace()?;\n write_skill(ws.user_skills_dir(), \"global-alpha\", \"Global alpha skill\")?;\n write_skill(\n ws.workspace().join(\".agents\").join(\"skills\"),\n \"workspace-beta\",\n \"Workspace beta skill\",\n )?;\n\n let mut h = Harness::builder(Harness::cargo_bin(\"codewhale-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n h.send(keys::key::text(\"/skills\"))?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for_text(\"Available skills\", KEY_TIMEOUT)?;\n h.wait_for_text(\"global-alpha\", KEY_TIMEOUT)?;\n h.wait_for_text(\"workspace-beta\", KEY_TIMEOUT)?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n assert!(f.contains(\"global-alpha\"), \"global skill missing:\\n{dump}\");\n assert!(\n f.contains(\"workspace-beta\"),\n \"workspace skill missing:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n// ===========================================================================\n// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit\n// ===========================================================================\n\n/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,\n/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path\n/// inserts it into the composer. The trailing `\\n` should leave the composer\n/// holding the text, not start a turn.\n#[test]\nfn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n // ~200 chars matching the original report. Trailing newline is the\n // payload that historically triggered the auto-submit.\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste(payload)?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n\n // Auto-submit would replace the composer with a \"working / thinking\"\n // status chip and clear the composer text. Either signal indicates the\n // bug fired.\n assert!(\n !f.contains(\"Working\") && !f.contains(\"thinking\") && !f.contains(\"Thinking\"),\n \"bracketed paste with trailing newline auto-submitted:\\n{dump}\"\n );\n assert!(\n f.contains(\"first line\") || f.contains(\"third line\"),\n \"pasted text should be visible in composer:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm\n/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is\n/// supposed to recognize the rapid stream and treat it as a single paste, but\n/// historically the trailing `\\r` (Enter) of the burst leaks through and\n/// triggers submit while the burst flush dumps the text into the now-empty\n/// composer.\n///\n/// This is the Windows / PowerShell repro from #1073.\n#[test]\nfn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n // Let the boot fully settle so input handling is wired up.\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste_unbracketed(payload)?;\n h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n eprintln!(\"=== AFTER UNBRACKETED PASTE ===\\n{dump}\");\n\n // The visible signal of an auto-submit: the text appears in the\n // transcript above the composer (sent as a user message). The composer\n // is also typically reset, but #1073 reports residual text in addition\n // to the auto-submit, so checking the transcript is more reliable.\n let count = dump.matches(\"first line\").count();\n assert!(\n count <= 1,\n \"'first line' appears {count} times — auto-submitted into transcript AND \\\n composer:\\n{dump}\"\n );\n // And the pasted text should be visible somewhere.\n assert!(\n f.contains(\"first line\"),\n \"pasted text should be on-screen somewhere:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n"}, "8da9fb7d5256783e63c79373f13ca4065040c6f2": {"t": 1785756830.16892, "n": 10521, "h": "be72af9de78e3133", "c": "//! End-to-end TUI scenarios driven through a real pseudo-terminal.\n//!\n//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,\n//! sends scripted input through the PTY, and asserts on the parsed terminal\n//! frame and on the workspace filesystem. See `support/qa_harness/README.md`\n//! for design + how-to.\n//!\n//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,\n//! #765, #802) needs a separate audit before scenarios light up there.\n\n#![cfg(unix)]\n\n#[path = \"support/qa_harness/mod.rs\"]\nmod qa_harness;\n\nuse std::time::Duration;\n\nuse qa_harness::harness::{Harness, make_sealed_workspace};\nuse qa_harness::keys;\n\nconst BOOT_TIMEOUT: Duration = Duration::from_secs(15);\nconst KEY_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n spawn_minimal(ws)\n}\n\nfn boot_minimal_without_retry() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n std::fs::write(\n ws.home().join(\".deepseek\").join(\"config.toml\"),\n \"[retry]\\nenabled = false\\n\",\n )?;\n spawn_minimal(ws)\n}\n\nfn spawn_minimal(\n ws: qa_harness::harness::SealedWorkspace,\n) -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let h = Harness::builder(Harness::cargo_bin(\"codewhale-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n // Provide a stub key so the onboarding screen is bypassed and the TUI\n // boots straight into the composer. The harness never makes a live\n // request — we just need the binary to think a key exists.\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n // Force a known base URL so the doctor / model probe never escapes\n // the box. 127.0.0.1:1 will refuse instantly.\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n Ok((ws, h))\n}\n\nfn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {\n let dir = root.join(name);\n std::fs::create_dir_all(&dir)?;\n std::fs::write(\n dir.join(\"SKILL.md\"),\n format!(\"---\\nname: {name}\\ndescription: {description}\\n---\\nUse {name}.\\n\"),\n )?;\n Ok(())\n}\n\nfn first_non_blank_row(frame: &qa_harness::Frame) -> Option {\n (0..frame.rows()).find(|&row| !frame.row(row).trim().is_empty())\n}\n\nfn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {\n let dump = frame.debug_dump();\n let first_row = first_non_blank_row(frame).expect(\"expected visible frame text\");\n assert_eq!(\n first_row, 0,\n \"viewport content drifted below row 0:\\n{dump}\"\n );\n assert!(\n frame.row(0).contains(\"Plan\")\n || frame.row(0).contains(\"Agent\")\n || frame.row(0).contains(\"Yolo\")\n || frame.row(0).contains(\"DeepSeek\"),\n \"expected header content on row 0:\\n{dump}\"\n );\n}\n\n/// Smoke: the binary boots into an alt-screen, paints a composer, and the\n/// header shows the project label. If this fails, the harness itself is\n/// broken before we worry about any scenario.\n#[test]\nfn smoke_boot_paints_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n\n // The composer panel border is labelled \"Composer\" — wait for it.\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n let f = h.frame();\n assert!(\n f.any_visible_text(),\n \"expected non-empty frame after boot:\\n{}\",\n f.debug_dump()\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression for #1085: after a turn exits through the error path, terminal\n/// origin/scroll-region state must not leave blank rows above the TUI.\n#[test]\nfn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal_without_retry()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n assert_viewport_starts_at_top(h.frame());\n\n h.send(keys::key::text(\"trigger a failed turn\"))?;\n h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for(\n |frame| {\n frame.contains(\"Turn failed\")\n || frame.contains(\"Connection refused\")\n || frame.contains(\"error\")\n },\n Duration::from_secs(15),\n )?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n assert_viewport_starts_at_top(h.frame());\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Verifies the harness actually sees keystrokes — type a character and watch\n/// it appear in the composer. This is the lowest-effort sanity check before\n/// we lean on it for real scenarios.\n#[test]\nfn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n h.send(keys::key::text(\"hello-from-pty\"))?;\n h.wait_for_text(\"hello-from-pty\", KEY_TIMEOUT)?;\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression: `/skills` should reflect the same merged discovery set as the\n/// slash menu and model-visible skills block, not just the first selected\n/// skills directory.\n#[test]\nfn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {\n let ws = make_sealed_workspace()?;\n write_skill(ws.user_skills_dir(), \"global-alpha\", \"Global alpha skill\")?;\n write_skill(\n ws.workspace().join(\".agents\").join(\"skills\"),\n \"workspace-beta\",\n \"Workspace beta skill\",\n )?;\n\n let mut h = Harness::builder(Harness::cargo_bin(\"codewhale-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n h.send(keys::key::text(\"/skills\"))?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for_text(\"Available skills\", KEY_TIMEOUT)?;\n h.wait_for_text(\"global-alpha\", KEY_TIMEOUT)?;\n h.wait_for_text(\"workspace-beta\", KEY_TIMEOUT)?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n assert!(f.contains(\"global-alpha\"), \"global skill missing:\\n{dump}\");\n assert!(\n f.contains(\"workspace-beta\"),\n \"workspace skill missing:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n// ===========================================================================\n// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit\n// ===========================================================================\n\n/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,\n/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path\n/// inserts it into the composer. The trailing `\\n` should leave the composer\n/// holding the text, not start a turn.\n#[test]\nfn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n // ~200 chars matching the original report. Trailing newline is the\n // payload that historically triggered the auto-submit.\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste(payload)?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n\n // Auto-submit would replace the composer with a \"working / thinking\"\n // status chip and clear the composer text. Either signal indicates the\n // bug fired.\n assert!(\n !f.contains(\"Working\") && !f.contains(\"thinking\") && !f.contains(\"Thinking\"),\n \"bracketed paste with trailing newline auto-submitted:\\n{dump}\"\n );\n assert!(\n f.contains(\"first line\") || f.contains(\"third line\"),\n \"pasted text should be visible in composer:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm\n/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is\n/// supposed to recognize the rapid stream and treat it as a single paste, but\n/// historically the trailing `\\r` (Enter) of the burst leaks through and\n/// triggers submit while the burst flush dumps the text into the now-empty\n/// composer.\n///\n/// This is the Windows / PowerShell repro from #1073.\n#[test]\nfn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n // Let the boot fully settle so input handling is wired up.\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste_unbracketed(payload)?;\n h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n eprintln!(\"=== AFTER UNBRACKETED PASTE ===\\n{dump}\");\n\n // The visible signal of an auto-submit: the text appears in the\n // transcript above the composer (sent as a user message). The composer\n // is also typically reset, but #1073 reports residual text in addition\n // to the auto-submit, so checking the transcript is more reliable.\n let count = dump.matches(\"first line\").count();\n assert!(\n count <= 1,\n \"'first line' appears {count} times — auto-submitted into transcript AND \\\n composer:\\n{dump}\"\n );\n // And the pasted text should be visible somewhere.\n assert!(\n f.contains(\"first line\"),\n \"pasted text should be on-screen somewhere:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n"}, "245e409a20f20cdac4602ea49dc5d595e95a8d34": {"t": 1785756831.4339354, "n": 10519, "h": "6333b757a1357507", "c": "//! End-to-end TUI scenarios driven through a real pseudo-terminal.\n//!\n//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,\n//! sends scripted input through the PTY, and asserts on the parsed terminal\n//! frame and on the workspace filesystem. See `support/qa_harness/README.md`\n//! for design + how-to.\n//!\n//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,\n//! #765, #802) needs a separate audit before scenarios light up there.\n\n#![cfg(unix)]\n\n#[path = \"support/qa_harness/mod.rs\"]\nmod qa_harness;\n\nuse std::time::Duration;\n\nuse qa_harness::harness::{Harness, make_sealed_workspace};\nuse qa_harness::keys;\n\nconst BOOT_TIMEOUT: Duration = Duration::from_secs(15);\nconst KEY_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n spawn_minimal(ws)\n}\n\nfn boot_minimal_without_retry() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n std::fs::write(\n ws.home().join(\".deepseek\").join(\"config.toml\"),\n \"[retry]\\nenabled = false\\n\",\n )?;\n spawn_minimal(ws)\n}\n\nfn spawn_minimal(\n ws: qa_harness::harness::SealedWorkspace,\n) -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let h = Harness::builder(Harness::cargo_bin(\"deepseek-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n // Provide a stub key so the onboarding screen is bypassed and the TUI\n // boots straight into the composer. The harness never makes a live\n // request — we just need the binary to think a key exists.\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n // Force a known base URL so the doctor / model probe never escapes\n // the box. 127.0.0.1:1 will refuse instantly.\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n Ok((ws, h))\n}\n\nfn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {\n let dir = root.join(name);\n std::fs::create_dir_all(&dir)?;\n std::fs::write(\n dir.join(\"SKILL.md\"),\n format!(\"---\\nname: {name}\\ndescription: {description}\\n---\\nUse {name}.\\n\"),\n )?;\n Ok(())\n}\n\nfn first_non_blank_row(frame: &qa_harness::Frame) -> Option {\n (0..frame.rows()).find(|&row| !frame.row(row).trim().is_empty())\n}\n\nfn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {\n let dump = frame.debug_dump();\n let first_row = first_non_blank_row(frame).expect(\"expected visible frame text\");\n assert_eq!(\n first_row, 0,\n \"viewport content drifted below row 0:\\n{dump}\"\n );\n assert!(\n frame.row(0).contains(\"Plan\")\n || frame.row(0).contains(\"Agent\")\n || frame.row(0).contains(\"Yolo\")\n || frame.row(0).contains(\"DeepSeek\"),\n \"expected header content on row 0:\\n{dump}\"\n );\n}\n\n/// Smoke: the binary boots into an alt-screen, paints a composer, and the\n/// header shows the project label. If this fails, the harness itself is\n/// broken before we worry about any scenario.\n#[test]\nfn smoke_boot_paints_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n\n // The composer panel border is labelled \"Composer\" — wait for it.\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n let f = h.frame();\n assert!(\n f.any_visible_text(),\n \"expected non-empty frame after boot:\\n{}\",\n f.debug_dump()\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression for #1085: after a turn exits through the error path, terminal\n/// origin/scroll-region state must not leave blank rows above the TUI.\n#[test]\nfn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal_without_retry()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n assert_viewport_starts_at_top(h.frame());\n\n h.send(keys::key::text(\"trigger a failed turn\"))?;\n h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for(\n |frame| {\n frame.contains(\"Turn failed\")\n || frame.contains(\"Connection refused\")\n || frame.contains(\"error\")\n },\n Duration::from_secs(15),\n )?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n assert_viewport_starts_at_top(h.frame());\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Verifies the harness actually sees keystrokes — type a character and watch\n/// it appear in the composer. This is the lowest-effort sanity check before\n/// we lean on it for real scenarios.\n#[test]\nfn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n h.send(keys::key::text(\"hello-from-pty\"))?;\n h.wait_for_text(\"hello-from-pty\", KEY_TIMEOUT)?;\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression: `/skills` should reflect the same merged discovery set as the\n/// slash menu and model-visible skills block, not just the first selected\n/// skills directory.\n#[test]\nfn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {\n let ws = make_sealed_workspace()?;\n write_skill(ws.user_skills_dir(), \"global-alpha\", \"Global alpha skill\")?;\n write_skill(\n ws.workspace().join(\".agents\").join(\"skills\"),\n \"workspace-beta\",\n \"Workspace beta skill\",\n )?;\n\n let mut h = Harness::builder(Harness::cargo_bin(\"deepseek-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n h.send(keys::key::text(\"/skills\"))?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for_text(\"Available skills\", KEY_TIMEOUT)?;\n h.wait_for_text(\"global-alpha\", KEY_TIMEOUT)?;\n h.wait_for_text(\"workspace-beta\", KEY_TIMEOUT)?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n assert!(f.contains(\"global-alpha\"), \"global skill missing:\\n{dump}\");\n assert!(\n f.contains(\"workspace-beta\"),\n \"workspace skill missing:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n// ===========================================================================\n// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit\n// ===========================================================================\n\n/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,\n/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path\n/// inserts it into the composer. The trailing `\\n` should leave the composer\n/// holding the text, not start a turn.\n#[test]\nfn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n // ~200 chars matching the original report. Trailing newline is the\n // payload that historically triggered the auto-submit.\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste(payload)?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n\n // Auto-submit would replace the composer with a \"working / thinking\"\n // status chip and clear the composer text. Either signal indicates the\n // bug fired.\n assert!(\n !f.contains(\"Working\") && !f.contains(\"thinking\") && !f.contains(\"Thinking\"),\n \"bracketed paste with trailing newline auto-submitted:\\n{dump}\"\n );\n assert!(\n f.contains(\"first line\") || f.contains(\"third line\"),\n \"pasted text should be visible in composer:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm\n/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is\n/// supposed to recognize the rapid stream and treat it as a single paste, but\n/// historically the trailing `\\r` (Enter) of the burst leaks through and\n/// triggers submit while the burst flush dumps the text into the now-empty\n/// composer.\n///\n/// This is the Windows / PowerShell repro from #1073.\n#[test]\nfn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n // Let the boot fully settle so input handling is wired up.\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste_unbracketed(payload)?;\n h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n eprintln!(\"=== AFTER UNBRACKETED PASTE ===\\n{dump}\");\n\n // The visible signal of an auto-submit: the text appears in the\n // transcript above the composer (sent as a user message). The composer\n // is also typically reset, but #1073 reports residual text in addition\n // to the auto-submit, so checking the transcript is more reliable.\n let count = dump.matches(\"first line\").count();\n assert!(\n count <= 1,\n \"'first line' appears {count} times — auto-submitted into transcript AND \\\n composer:\\n{dump}\"\n );\n // And the pasted text should be visible somewhere.\n assert!(\n f.contains(\"first line\"),\n \"pasted text should be on-screen somewhere:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n"}, "0f46acdd765db8df00c2e762a054f6c35d5f680e": {"t": 1785756832.5859988, "n": 8459, "h": "295a06776ff8cb7c", "c": "//! End-to-end TUI scenarios driven through a real pseudo-terminal.\n//!\n//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,\n//! sends scripted input through the PTY, and asserts on the parsed terminal\n//! frame and on the workspace filesystem. See `support/qa_harness/README.md`\n//! for design + how-to.\n//!\n//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,\n//! #765, #802) needs a separate audit before scenarios light up there.\n\n#![cfg(unix)]\n\n#[path = \"support/qa_harness/mod.rs\"]\nmod qa_harness;\n\nuse std::time::Duration;\n\nuse qa_harness::harness::{Harness, make_sealed_workspace};\nuse qa_harness::keys;\n\nconst BOOT_TIMEOUT: Duration = Duration::from_secs(15);\nconst KEY_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n let h = Harness::builder(Harness::cargo_bin(\"deepseek-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n // Provide a stub key so the onboarding screen is bypassed and the TUI\n // boots straight into the composer. The harness never makes a live\n // request — we just need the binary to think a key exists.\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n // Force a known base URL so the doctor / model probe never escapes\n // the box. 127.0.0.1:1 will refuse instantly.\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n Ok((ws, h))\n}\n\nfn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {\n let dir = root.join(name);\n std::fs::create_dir_all(&dir)?;\n std::fs::write(\n dir.join(\"SKILL.md\"),\n format!(\"---\\nname: {name}\\ndescription: {description}\\n---\\nUse {name}.\\n\"),\n )?;\n Ok(())\n}\n\n/// Smoke: the binary boots into an alt-screen, paints a composer, and the\n/// header shows the project label. If this fails, the harness itself is\n/// broken before we worry about any scenario.\n#[test]\nfn smoke_boot_paints_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n\n // The composer panel border is labelled \"Composer\" — wait for it.\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n let f = h.frame();\n assert!(\n f.any_visible_text(),\n \"expected non-empty frame after boot:\\n{}\",\n f.debug_dump()\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Verifies the harness actually sees keystrokes — type a character and watch\n/// it appear in the composer. This is the lowest-effort sanity check before\n/// we lean on it for real scenarios.\n#[test]\nfn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n h.send(keys::key::text(\"hello-from-pty\"))?;\n h.wait_for_text(\"hello-from-pty\", KEY_TIMEOUT)?;\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Regression: `/skills` should reflect the same merged discovery set as the\n/// slash menu and model-visible skills block, not just the first selected\n/// skills directory.\n#[test]\nfn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {\n let ws = make_sealed_workspace()?;\n write_skill(ws.user_skills_dir(), \"global-alpha\", \"Global alpha skill\")?;\n write_skill(\n ws.workspace().join(\".agents\").join(\"skills\"),\n \"workspace-beta\",\n \"Workspace beta skill\",\n )?;\n\n let mut h = Harness::builder(Harness::cargo_bin(\"deepseek-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n h.send(keys::key::text(\"/skills\"))?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n h.send(keys::key::enter())?;\n h.wait_for_text(\"Available skills\", KEY_TIMEOUT)?;\n h.wait_for_text(\"global-alpha\", KEY_TIMEOUT)?;\n h.wait_for_text(\"workspace-beta\", KEY_TIMEOUT)?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n assert!(f.contains(\"global-alpha\"), \"global skill missing:\\n{dump}\");\n assert!(\n f.contains(\"workspace-beta\"),\n \"workspace skill missing:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n// ===========================================================================\n// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit\n// ===========================================================================\n\n/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,\n/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path\n/// inserts it into the composer. The trailing `\\n` should leave the composer\n/// holding the text, not start a turn.\n#[test]\nfn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n // ~200 chars matching the original report. Trailing newline is the\n // payload that historically triggered the auto-submit.\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste(payload)?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n\n // Auto-submit would replace the composer with a \"working / thinking\"\n // status chip and clear the composer text. Either signal indicates the\n // bug fired.\n assert!(\n !f.contains(\"Working\") && !f.contains(\"thinking\") && !f.contains(\"Thinking\"),\n \"bracketed paste with trailing newline auto-submitted:\\n{dump}\"\n );\n assert!(\n f.contains(\"first line\") || f.contains(\"third line\"),\n \"pasted text should be visible in composer:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm\n/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is\n/// supposed to recognize the rapid stream and treat it as a single paste, but\n/// historically the trailing `\\r` (Enter) of the burst leaks through and\n/// triggers submit while the burst flush dumps the text into the now-empty\n/// composer.\n///\n/// This is the Windows / PowerShell repro from #1073.\n#[test]\nfn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n // Let the boot fully settle so input handling is wired up.\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste_unbracketed(payload)?;\n h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n eprintln!(\"=== AFTER UNBRACKETED PASTE ===\\n{dump}\");\n\n // The visible signal of an auto-submit: the text appears in the\n // transcript above the composer (sent as a user message). The composer\n // is also typically reset, but #1073 reports residual text in addition\n // to the auto-submit, so checking the transcript is more reliable.\n let count = dump.matches(\"first line\").count();\n assert!(\n count <= 1,\n \"'first line' appears {count} times — auto-submitted into transcript AND \\\n composer:\\n{dump}\"\n );\n // And the pasted text should be visible somewhere.\n assert!(\n f.contains(\"first line\"),\n \"pasted text should be on-screen somewhere:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n"}, "4e285595b0bc9dfd8a092cf6af93d71b8f649e6e": {"t": 1785756833.7497241, "n": 6495, "h": "b7b508e4f7982db9", "c": "//! End-to-end TUI scenarios driven through a real pseudo-terminal.\n//!\n//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,\n//! sends scripted input through the PTY, and asserts on the parsed terminal\n//! frame and on the workspace filesystem. See `support/qa_harness/README.md`\n//! for design + how-to.\n//!\n//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,\n//! #765, #802) needs a separate audit before scenarios light up there.\n\n#![cfg(unix)]\n\n#[path = \"support/qa_harness/mod.rs\"]\nmod qa_harness;\n\nuse std::time::Duration;\n\nuse qa_harness::harness::{Harness, make_sealed_workspace};\nuse qa_harness::keys;\n\nconst BOOT_TIMEOUT: Duration = Duration::from_secs(15);\nconst KEY_TIMEOUT: Duration = Duration::from_secs(5);\n\nfn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {\n let ws = make_sealed_workspace()?;\n let h = Harness::builder(Harness::cargo_bin(\"deepseek-tui\"))\n .cwd(ws.workspace())\n .seal_home(ws.home())\n // Provide a stub key so the onboarding screen is bypassed and the TUI\n // boots straight into the composer. The harness never makes a live\n // request — we just need the binary to think a key exists.\n .env(\"DEEPSEEK_API_KEY\", \"ci-test-key-not-real\")\n // Force a known base URL so the doctor / model probe never escapes\n // the box. 127.0.0.1:1 will refuse instantly.\n .env(\"DEEPSEEK_BASE_URL\", \"http://127.0.0.1:1\")\n .env(\"RUST_LOG\", \"warn\")\n .args([\n \"--workspace\",\n ws.workspace().to_str().expect(\"utf-8 workspace path\"),\n \"--no-project-config\",\n \"--skip-onboarding\",\n ])\n .size(40, 140)\n .spawn()?;\n Ok((ws, h))\n}\n\n/// Smoke: the binary boots into an alt-screen, paints a composer, and the\n/// header shows the project label. If this fails, the harness itself is\n/// broken before we worry about any scenario.\n#[test]\nfn smoke_boot_paints_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n\n // The composer panel border is labelled \"Composer\" — wait for it.\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n let f = h.frame();\n assert!(\n f.any_visible_text(),\n \"expected non-empty frame after boot:\\n{}\",\n f.debug_dump()\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Verifies the harness actually sees keystrokes — type a character and watch\n/// it appear in the composer. This is the lowest-effort sanity check before\n/// we lean on it for real scenarios.\n#[test]\nfn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n h.send(keys::key::text(\"hello-from-pty\"))?;\n h.wait_for_text(\"hello-from-pty\", KEY_TIMEOUT)?;\n\n let _ = h.shutdown();\n Ok(())\n}\n\n// ===========================================================================\n// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit\n// ===========================================================================\n\n/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,\n/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path\n/// inserts it into the composer. The trailing `\\n` should leave the composer\n/// holding the text, not start a turn.\n#[test]\nfn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n\n // ~200 chars matching the original report. Trailing newline is the\n // payload that historically triggered the auto-submit.\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste(payload)?;\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n\n // Auto-submit would replace the composer with a \"working / thinking\"\n // status chip and clear the composer text. Either signal indicates the\n // bug fired.\n assert!(\n !f.contains(\"Working\") && !f.contains(\"thinking\") && !f.contains(\"Thinking\"),\n \"bracketed paste with trailing newline auto-submitted:\\n{dump}\"\n );\n assert!(\n f.contains(\"first line\") || f.contains(\"third line\"),\n \"pasted text should be visible in composer:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n\n/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm\n/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is\n/// supposed to recognize the rapid stream and treat it as a single paste, but\n/// historically the trailing `\\r` (Enter) of the burst leaks through and\n/// triggers submit while the burst flush dumps the text into the now-empty\n/// composer.\n///\n/// This is the Windows / PowerShell repro from #1073.\n#[test]\nfn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {\n let (_ws, mut h) = boot_minimal()?;\n h.wait_for_text(\"Composer\", BOOT_TIMEOUT)?;\n // Let the boot fully settle so input handling is wired up.\n h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;\n\n let payload = \"first line of the multi-line paste body\\n\\\n second line continuing the paragraph until the end\\n\\\n third line that finishes with a trailing newline character\\n\";\n h.paste_unbracketed(payload)?;\n h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;\n\n let f = h.frame();\n let dump = f.debug_dump();\n eprintln!(\"=== AFTER UNBRACKETED PASTE ===\\n{dump}\");\n\n // The visible signal of an auto-submit: the text appears in the\n // transcript above the composer (sent as a user message). The composer\n // is also typically reset, but #1073 reports residual text in addition\n // to the auto-submit, so checking the transcript is more reliable.\n let count = dump.matches(\"first line\").count();\n assert!(\n count <= 1,\n \"'first line' appears {count} times — auto-submitted into transcript AND \\\n composer:\\n{dump}\"\n );\n // And the pasted text should be visible somewhere.\n assert!(\n f.contains(\"first line\"),\n \"pasted text should be on-screen somewhere:\\n{dump}\"\n );\n\n let _ = h.shutdown();\n Ok(())\n}\n"}}, "crates/tui/src/tools/fetch_url.rs": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785746250.6009195, "n": 30848, "h": "dd101af914fa215b", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::handle::query_jsonpath;\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::collections::BTreeMap;\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; codewhale/0.5; +https://github.com/Hmbown/CodeWhale)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n headers: BTreeMap,\n content_type: String,\n content: String,\n truncated: bool,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n fields: Option>>,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n },\n \"fields\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Optional JSONPath projections for JSON responses. Supports $, .field, [index], [*], and ['field']; returns matches under `fields`.\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let requested_fields = parse_fields(&input)?;\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n let headers = response_headers(resp.headers());\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let fields = project_json_fields(&body_text, &content_type, &requested_fields)?;\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\nfn parse_fields(input: &Value) -> Result, ToolError> {\n let Some(values) = input.get(\"fields\") else {\n return Ok(Vec::new());\n };\n let Some(values) = values.as_array() else {\n return Err(ToolError::invalid_input(\"`fields` must be an array\"));\n };\n let mut fields = Vec::new();\n for value in values {\n let Some(field) = value.as_str() else {\n return Err(ToolError::invalid_input(\n \"`fields` entries must be JSONPath strings\",\n ));\n };\n let field = field.trim();\n if !field.is_empty() {\n fields.push(field.to_string());\n }\n }\n Ok(fields)\n}\n\nfn response_headers(headers: &reqwest::header::HeaderMap) -> BTreeMap {\n headers\n .iter()\n .filter_map(|(name, value)| {\n value\n .to_str()\n .ok()\n .map(|value| (name.as_str().to_ascii_lowercase(), value.to_string()))\n })\n .collect()\n}\n\nfn project_json_fields(\n body_text: &str,\n content_type: &str,\n fields: &[String],\n) -> Result>>, ToolError> {\n if fields.is_empty() {\n return Ok(None);\n }\n if !content_type.to_ascii_lowercase().contains(\"json\") {\n return Err(ToolError::invalid_input(\n \"`fields` can only be used with JSON responses\",\n ));\n }\n let body_json: Value = serde_json::from_str(body_text).map_err(|e| {\n ToolError::execution_failed(format!(\"response body is not valid JSON for `fields`: {e}\"))\n })?;\n let mut out = BTreeMap::new();\n for field in fields {\n let matches = query_jsonpath(&body_json, field).map_err(|e| {\n ToolError::invalid_input(format!(\"invalid JSONPath `{field}` in `fields`: {e}\"))\n })?;\n out.insert(field.clone(), matches);\n }\n Ok(Some(out))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[test]\n fn project_json_fields_returns_requested_jsonpath_matches() {\n let fields = vec![\"$.items[*].name\".to_string(), \"$.count\".to_string()];\n let projected = project_json_fields(\n r#\"{\"items\":[{\"name\":\"alpha\"},{\"name\":\"beta\"}],\"count\":2}\"#,\n \"application/json\",\n &fields,\n )\n .expect(\"project\")\n .expect(\"some\");\n\n assert_eq!(\n projected.get(\"$.items[*].name\").unwrap(),\n &vec![json!(\"alpha\"), json!(\"beta\")]\n );\n assert_eq!(projected.get(\"$.count\").unwrap(), &vec![json!(2)]);\n }\n\n #[test]\n fn project_json_fields_rejects_non_json_content_type() {\n let fields = vec![\"$.name\".to_string()];\n let err = project_json_fields(\"{}\", \"text/plain\", &fields).expect_err(\"must reject\");\n assert!(format!(\"{err}\").contains(\"JSON responses\"));\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "5fa24733e9a1d11315506309b8edb0fdf2b94f04": {"t": 1785756830.612179, "n": 30848, "h": "dd101af914fa215b", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::handle::query_jsonpath;\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::collections::BTreeMap;\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; codewhale/0.5; +https://github.com/Hmbown/CodeWhale)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n headers: BTreeMap,\n content_type: String,\n content: String,\n truncated: bool,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n fields: Option>>,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n },\n \"fields\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Optional JSONPath projections for JSON responses. Supports $, .field, [index], [*], and ['field']; returns matches under `fields`.\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let requested_fields = parse_fields(&input)?;\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n let headers = response_headers(resp.headers());\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let fields = project_json_fields(&body_text, &content_type, &requested_fields)?;\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\nfn parse_fields(input: &Value) -> Result, ToolError> {\n let Some(values) = input.get(\"fields\") else {\n return Ok(Vec::new());\n };\n let Some(values) = values.as_array() else {\n return Err(ToolError::invalid_input(\"`fields` must be an array\"));\n };\n let mut fields = Vec::new();\n for value in values {\n let Some(field) = value.as_str() else {\n return Err(ToolError::invalid_input(\n \"`fields` entries must be JSONPath strings\",\n ));\n };\n let field = field.trim();\n if !field.is_empty() {\n fields.push(field.to_string());\n }\n }\n Ok(fields)\n}\n\nfn response_headers(headers: &reqwest::header::HeaderMap) -> BTreeMap {\n headers\n .iter()\n .filter_map(|(name, value)| {\n value\n .to_str()\n .ok()\n .map(|value| (name.as_str().to_ascii_lowercase(), value.to_string()))\n })\n .collect()\n}\n\nfn project_json_fields(\n body_text: &str,\n content_type: &str,\n fields: &[String],\n) -> Result>>, ToolError> {\n if fields.is_empty() {\n return Ok(None);\n }\n if !content_type.to_ascii_lowercase().contains(\"json\") {\n return Err(ToolError::invalid_input(\n \"`fields` can only be used with JSON responses\",\n ));\n }\n let body_json: Value = serde_json::from_str(body_text).map_err(|e| {\n ToolError::execution_failed(format!(\"response body is not valid JSON for `fields`: {e}\"))\n })?;\n let mut out = BTreeMap::new();\n for field in fields {\n let matches = query_jsonpath(&body_json, field).map_err(|e| {\n ToolError::invalid_input(format!(\"invalid JSONPath `{field}` in `fields`: {e}\"))\n })?;\n out.insert(field.clone(), matches);\n }\n Ok(Some(out))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[test]\n fn project_json_fields_returns_requested_jsonpath_matches() {\n let fields = vec![\"$.items[*].name\".to_string(), \"$.count\".to_string()];\n let projected = project_json_fields(\n r#\"{\"items\":[{\"name\":\"alpha\"},{\"name\":\"beta\"}],\"count\":2}\"#,\n \"application/json\",\n &fields,\n )\n .expect(\"project\")\n .expect(\"some\");\n\n assert_eq!(\n projected.get(\"$.items[*].name\").unwrap(),\n &vec![json!(\"alpha\"), json!(\"beta\")]\n );\n assert_eq!(projected.get(\"$.count\").unwrap(), &vec![json!(2)]);\n }\n\n #[test]\n fn project_json_fields_rejects_non_json_content_type() {\n let fields = vec![\"$.name\".to_string()];\n let err = project_json_fields(\"{}\", \"text/plain\", &fields).expect_err(\"must reject\");\n assert!(format!(\"{err}\").contains(\"JSON responses\"));\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "32ce14d6b21f89caa7514d8626bb99e655b219fd": {"t": 1785756831.9610136, "n": 30851, "h": "89a4916916834352", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::handle::query_jsonpath;\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::collections::BTreeMap;\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; codewhale/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n headers: BTreeMap,\n content_type: String,\n content: String,\n truncated: bool,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n fields: Option>>,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n },\n \"fields\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Optional JSONPath projections for JSON responses. Supports $, .field, [index], [*], and ['field']; returns matches under `fields`.\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let requested_fields = parse_fields(&input)?;\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n let headers = response_headers(resp.headers());\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let fields = project_json_fields(&body_text, &content_type, &requested_fields)?;\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\nfn parse_fields(input: &Value) -> Result, ToolError> {\n let Some(values) = input.get(\"fields\") else {\n return Ok(Vec::new());\n };\n let Some(values) = values.as_array() else {\n return Err(ToolError::invalid_input(\"`fields` must be an array\"));\n };\n let mut fields = Vec::new();\n for value in values {\n let Some(field) = value.as_str() else {\n return Err(ToolError::invalid_input(\n \"`fields` entries must be JSONPath strings\",\n ));\n };\n let field = field.trim();\n if !field.is_empty() {\n fields.push(field.to_string());\n }\n }\n Ok(fields)\n}\n\nfn response_headers(headers: &reqwest::header::HeaderMap) -> BTreeMap {\n headers\n .iter()\n .filter_map(|(name, value)| {\n value\n .to_str()\n .ok()\n .map(|value| (name.as_str().to_ascii_lowercase(), value.to_string()))\n })\n .collect()\n}\n\nfn project_json_fields(\n body_text: &str,\n content_type: &str,\n fields: &[String],\n) -> Result>>, ToolError> {\n if fields.is_empty() {\n return Ok(None);\n }\n if !content_type.to_ascii_lowercase().contains(\"json\") {\n return Err(ToolError::invalid_input(\n \"`fields` can only be used with JSON responses\",\n ));\n }\n let body_json: Value = serde_json::from_str(body_text).map_err(|e| {\n ToolError::execution_failed(format!(\"response body is not valid JSON for `fields`: {e}\"))\n })?;\n let mut out = BTreeMap::new();\n for field in fields {\n let matches = query_jsonpath(&body_json, field).map_err(|e| {\n ToolError::invalid_input(format!(\"invalid JSONPath `{field}` in `fields`: {e}\"))\n })?;\n out.insert(field.clone(), matches);\n }\n Ok(Some(out))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[test]\n fn project_json_fields_returns_requested_jsonpath_matches() {\n let fields = vec![\"$.items[*].name\".to_string(), \"$.count\".to_string()];\n let projected = project_json_fields(\n r#\"{\"items\":[{\"name\":\"alpha\"},{\"name\":\"beta\"}],\"count\":2}\"#,\n \"application/json\",\n &fields,\n )\n .expect(\"project\")\n .expect(\"some\");\n\n assert_eq!(\n projected.get(\"$.items[*].name\").unwrap(),\n &vec![json!(\"alpha\"), json!(\"beta\")]\n );\n assert_eq!(projected.get(\"$.count\").unwrap(), &vec![json!(2)]);\n }\n\n #[test]\n fn project_json_fields_rejects_non_json_content_type() {\n let fields = vec![\"$.name\".to_string()];\n let err = project_json_fields(\"{}\", \"text/plain\", &fields).expect_err(\"must reject\");\n assert!(format!(\"{err}\").contains(\"JSON responses\"));\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "99c6b22e83c5d72e039c64ca100a9764354cfeeb": {"t": 1785756833.7961743, "n": 30854, "h": "39ebe5acd0cc28fd", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::handle::query_jsonpath;\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::collections::BTreeMap;\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n headers: BTreeMap,\n content_type: String,\n content: String,\n truncated: bool,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n fields: Option>>,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n },\n \"fields\": {\n \"type\": \"array\",\n \"items\": { \"type\": \"string\" },\n \"description\": \"Optional JSONPath projections for JSON responses. Supports $, .field, [index], [*], and ['field']; returns matches under `fields`.\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let requested_fields = parse_fields(&input)?;\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n let headers = response_headers(resp.headers());\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let fields = project_json_fields(&body_text, &content_type, &requested_fields)?;\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\nfn parse_fields(input: &Value) -> Result, ToolError> {\n let Some(values) = input.get(\"fields\") else {\n return Ok(Vec::new());\n };\n let Some(values) = values.as_array() else {\n return Err(ToolError::invalid_input(\"`fields` must be an array\"));\n };\n let mut fields = Vec::new();\n for value in values {\n let Some(field) = value.as_str() else {\n return Err(ToolError::invalid_input(\n \"`fields` entries must be JSONPath strings\",\n ));\n };\n let field = field.trim();\n if !field.is_empty() {\n fields.push(field.to_string());\n }\n }\n Ok(fields)\n}\n\nfn response_headers(headers: &reqwest::header::HeaderMap) -> BTreeMap {\n headers\n .iter()\n .filter_map(|(name, value)| {\n value\n .to_str()\n .ok()\n .map(|value| (name.as_str().to_ascii_lowercase(), value.to_string()))\n })\n .collect()\n}\n\nfn project_json_fields(\n body_text: &str,\n content_type: &str,\n fields: &[String],\n) -> Result>>, ToolError> {\n if fields.is_empty() {\n return Ok(None);\n }\n if !content_type.to_ascii_lowercase().contains(\"json\") {\n return Err(ToolError::invalid_input(\n \"`fields` can only be used with JSON responses\",\n ));\n }\n let body_json: Value = serde_json::from_str(body_text).map_err(|e| {\n ToolError::execution_failed(format!(\"response body is not valid JSON for `fields`: {e}\"))\n })?;\n let mut out = BTreeMap::new();\n for field in fields {\n let matches = query_jsonpath(&body_json, field).map_err(|e| {\n ToolError::invalid_input(format!(\"invalid JSONPath `{field}` in `fields`: {e}\"))\n })?;\n out.insert(field.clone(), matches);\n }\n Ok(Some(out))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[test]\n fn project_json_fields_returns_requested_jsonpath_matches() {\n let fields = vec![\"$.items[*].name\".to_string(), \"$.count\".to_string()];\n let projected = project_json_fields(\n r#\"{\"items\":[{\"name\":\"alpha\"},{\"name\":\"beta\"}],\"count\":2}\"#,\n \"application/json\",\n &fields,\n )\n .expect(\"project\")\n .expect(\"some\");\n\n assert_eq!(\n projected.get(\"$.items[*].name\").unwrap(),\n &vec![json!(\"alpha\"), json!(\"beta\")]\n );\n assert_eq!(projected.get(\"$.count\").unwrap(), &vec![json!(2)]);\n }\n\n #[test]\n fn project_json_fields_rejects_non_json_content_type() {\n let fields = vec![\"$.name\".to_string()];\n let err = project_json_fields(\"{}\", \"text/plain\", &fields).expect_err(\"must reject\");\n assert!(format!(\"{err}\").contains(\"JSON responses\"));\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "9b2e747eebe5543cfe47b18071af407359ca7dea": {"t": 1785756835.3035655, "n": 27312, "h": "878a40c8d152adc1", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this instead of `curl` in `exec_shell` — sandboxed, network-policy aware, and properly decoded. Plain-text endpoints (`.md`, `.txt`, `.json`, `.yaml`, `raw.githubusercontent.com`, public APIs) prefer this over the browser/automation stack. For unknown queries, use `web_search` first.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "b92d3569fd2eb1ffd598fb0e59d8c7b009edce26": {"t": 1785756836.7910504, "n": 27162, "h": "60a4b94e5507d6e5", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n // Normalize bracketed IPv6 literals before the literal-IP check so they\n // route through the same restricted-IP policy as unbracketed forms\n // (GHSA-88gh-2526-gfrr).\n let ip_candidate = host\n .strip_prefix('[')\n .and_then(|s| s.strip_suffix(']'))\n .unwrap_or(host.as_str());\n if let Ok(ip) = ip_candidate.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n // GHSA-88gh-2526-gfrr — regression coverage for bracketed IPv6 literals.\n #[tokio::test]\n async fn rejects_ipv6_literal_loopback() {\n let url = reqwest::Url::parse(\"http://[::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ula() {\n let url = reqwest::Url::parse(\"http://[fc00::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fc00::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_link_local() {\n let url = reqwest::Url::parse(\"http://[fe80::1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[fe80::1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_ipv4_mapped_loopback() {\n let url = reqwest::Url::parse(\"http://[::ffff:127.0.0.1]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::ffff:127.0.0.1] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn rejects_ipv6_literal_unspecified() {\n let url = reqwest::Url::parse(\"http://[::]/\").unwrap();\n let err = validate_fetch_target(&url, &ctx())\n .await\n .expect_err(\"[::] must be rejected as restricted\");\n assert!(format!(\"{err}\").contains(\"restricted\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "8b60275981940d09dd8b15b757b60fb7411f59ee": {"t": 1785756838.2943733, "n": 25042, "h": "2c2d48fbfe2dfcac", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n let mut current_url = reqwest::Url::parse(&url)\n .map_err(|e| ToolError::invalid_input(format!(\"invalid URL: {e}\")))?;\n let mut redirects_followed = 0usize;\n\n let resp = loop {\n let dns_pinning = validate_fetch_target(¤t_url, context).await?;\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::none());\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(current_url.clone())\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n if !resp.status().is_redirection() || redirects_followed >= MAX_REDIRECTS {\n break resp;\n }\n\n let Some(location) = resp\n .headers()\n .get(reqwest::header::LOCATION)\n .and_then(|value| value.to_str().ok())\n else {\n break resp;\n };\n\n current_url = resp.url().join(location).map_err(|e| {\n ToolError::execution_failed(format!(\"invalid redirect location: {e}\"))\n })?;\n redirects_followed += 1;\n };\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nasync fn validate_fetch_target(\n url: &reqwest::Url,\n context: &ToolContext,\n) -> Result, ToolError> {\n if url.scheme() != \"http\" && url.scheme() != \"https\" {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let host = url\n .host_str()\n .map(str::to_ascii_lowercase)\n .ok_or_else(|| ToolError::invalid_input(\"URL must include a host\"))?;\n\n validate_network_policy(&host, context)?;\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n if let Ok(ip) = host.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n return Ok(None);\n }\n\n let mut first_valid: Option = None;\n if let Ok(addrs) = tokio::net::lookup_host((host.as_str(), 0u16)).await {\n for addr in addrs {\n validate_dns_resolved_ip(&host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n }\n\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n Ok(first_valid.map(|validated_ip| (host, validated_ip)))\n}\n\nfn validate_network_policy(host: &str, context: &ToolContext) -> Result<(), ToolError> {\n let Some(decider) = context.network_policy.as_ref() else {\n return Ok(());\n };\n\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => Ok(()),\n Decision::Deny => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n ))),\n Decision::Prompt => Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n ))),\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn redirected_localhost_hostname_is_rejected() {\n let url = reqwest::Url::parse(\"http://localhost:8080/admin\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn redirected_private_ip_literal_is_rejected() {\n let url = reqwest::Url::parse(\"http://169.254.169.254/latest/meta-data\").unwrap();\n let err = validate_fetch_target(&url, &ctx()).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"restricted address\"));\n }\n\n #[tokio::test]\n async fn redirected_host_respects_network_policy() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let url = reqwest::Url::parse(\"https://example.com/redirect-target\").unwrap();\n let err = validate_fetch_target(&url, &ctx).await.unwrap_err();\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "73f92bfb5a1abd9a326c66c5d455667684f36b7c": {"t": 1785756839.9697857, "n": 23125, "h": "3ff640fdc344e992", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, NetworkPolicyDecider, host_from_url};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n // Extract host once for reuse across network policy + SSRF checks.\n let url_host = host_from_url(&url);\n\n // Per-domain network policy gate (#135). If no policy is attached\n // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0.\n if let Some(decider) = context.network_policy.as_ref()\n && let Some(ref host) = url_host\n {\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => {}\n Decision::Deny => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n )));\n }\n Decision::Prompt => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n )));\n }\n }\n }\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n // Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding\n // TOCTOU window — reqwest will use the pinned IP instead of re-resolving.\n let mut dns_pinning = None; // (hostname, validated_ip)\n if let Some(host) = &url_host {\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n if let Ok(ip) = host.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n } else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await {\n let mut first_valid: Option = None;\n for addr in addrs {\n validate_dns_resolved_ip(host, &addr.ip(), context.network_policy.as_ref())?;\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n if let Some(validated_ip) = first_valid {\n dns_pinning = Some((host.clone(), validated_ip));\n }\n }\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(&url)\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\nfn validate_dns_resolved_ip(\n host: &str,\n ip: &std::net::IpAddr,\n decider: Option<&NetworkPolicyDecider>,\n) -> Result<(), ToolError> {\n if !is_restricted_ip(ip) {\n return Ok(());\n }\n\n if let Some(decider) = decider\n && decider.trusts_proxy_fakeip_host(host)\n {\n decider.record_trusted_proxy_fakeip_allow(host, \"fetch_url\");\n return Ok(());\n }\n\n Err(ToolError::permission_denied(format!(\n \"resolved IP {ip} is a restricted address (private/loopback/link-local)\"\n )))\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n proxy: Vec::new(),\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n\n #[test]\n fn restricted_dns_result_is_denied_without_proxy_opt_in() {\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"github.com\", &ip, None)\n .expect_err(\"fake-IP DNS result must be denied by default\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_opt_in_allows_restricted_dns_for_matching_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider))\n .expect(\"proxy opt-in should allow fake-IP DNS for matching host\");\n }\n\n #[test]\n fn proxy_opt_in_does_not_allow_unlisted_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ip = \"198.18.0.1\".parse().unwrap();\n\n let err = validate_dns_resolved_ip(\"example.com\", &ip, Some(&decider))\n .expect_err(\"proxy opt-in must be scoped to configured hosts\");\n\n assert!(format!(\"{err}\").contains(\"resolved IP 198.18.0.1 is a restricted address\"));\n }\n\n #[tokio::test]\n async fn proxy_opt_in_does_not_allow_restricted_ip_literal() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"198.18.0.1\".to_string()],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n\n let err = tool\n .execute(json!({\"url\": \"http://198.18.0.1/status\"}), &ctx)\n .await\n .expect_err(\"literal restricted IP URLs must stay blocked\");\n\n assert!(format!(\"{err}\").contains(\"IP 198.18.0.1 is a restricted address\"));\n }\n\n #[test]\n fn proxy_dns_allow_is_audited() {\n use crate::network_policy::{\n Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,\n };\n use tempfile::tempdir;\n\n let dir = tempdir().expect(\"tempdir\");\n let auditor = NetworkAuditor::new(dir.path().join(\"audit.log\"), true);\n let policy = NetworkPolicy {\n default: Decision::Allow.into(),\n allow: Vec::new(),\n deny: Vec::new(),\n proxy: vec![\"github.com\".to_string()],\n audit: true,\n };\n let decider = NetworkPolicyDecider::new(policy, Some(auditor));\n let ip = \"198.18.0.1\".parse().unwrap();\n\n validate_dns_resolved_ip(\"github.com\", &ip, Some(&decider)).expect(\"proxy DNS allow\");\n\n let body = std::fs::read_to_string(dir.path().join(\"audit.log\")).expect(\"audit log\");\n assert!(body.contains(\"github.com\"));\n assert!(body.contains(\"TrustedProxyFakeIp-Allow\"));\n }\n}\n"}, "2d61513a9ea762fd2ffb3565e0dd819a400e8532": {"t": 1785756841.537522, "n": 19130, "h": "fb695e060af440d8", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, host_from_url};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n // Extract host once for reuse across network policy + SSRF checks.\n let url_host = host_from_url(&url);\n\n // Per-domain network policy gate (#135). If no policy is attached\n // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0.\n if let Some(decider) = context.network_policy.as_ref()\n && let Some(ref host) = url_host\n {\n match decider.evaluate(host, \"fetch_url\") {\n Decision::Allow => {}\n Decision::Deny => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n )));\n }\n Decision::Prompt => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n )));\n }\n }\n }\n\n // SSRF protection: resolve hostname and reject private/link-local/loopback IPs.\n // Prevents LLM-prompted requests to cloud metadata (169.254.169.254),\n // localhost services, and internal networks.\n // Pin the validated IP via ClientBuilder::resolve() to close the DNS rebinding\n // TOCTOU window — reqwest will use the pinned IP instead of re-resolving.\n let mut dns_pinning = None; // (hostname, validated_ip)\n if let Some(host) = &url_host {\n if host == \"localhost\" || host == \"localhost.localdomain\" {\n return Err(ToolError::permission_denied(\n \"requests to localhost are not allowed\",\n ));\n }\n if let Ok(ip) = host.parse::() {\n if is_restricted_ip(&ip) {\n return Err(ToolError::permission_denied(format!(\n \"IP {ip} is a restricted address (private/loopback/link-local)\"\n )));\n }\n } else if let Ok(addrs) = tokio::net::lookup_host((&**host, 0u16)).await {\n let mut first_valid: Option = None;\n for addr in addrs {\n if is_restricted_ip(&addr.ip()) {\n return Err(ToolError::permission_denied(format!(\n \"resolved IP {} is a restricted address (private/loopback/link-local)\",\n addr.ip()\n )));\n }\n if first_valid.is_none() {\n first_valid = Some(addr.ip());\n }\n }\n if let Some(validated_ip) = first_valid {\n dns_pinning = Some((host.clone(), validated_ip));\n }\n }\n // If DNS resolution fails, let the HTTP request proceed and fail naturally.\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n\n let mut client_builder = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));\n\n // Pin validated IP to prevent DNS rebinding (TOCTOU) — reqwest will\n // connect to the validated IP directly instead of re-resolving.\n if let Some((hostname, validated_ip)) = dns_pinning {\n client_builder =\n client_builder.resolve(&hostname, std::net::SocketAddr::new(validated_ip, 0));\n }\n\n let client = client_builder.build().map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(&url)\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\" bool {\n match ip {\n std::net::IpAddr::V4(v4) => {\n v4.is_loopback()\n || v4.is_private()\n || v4.is_link_local()\n || v4.is_multicast()\n || v4.is_broadcast()\n || v4.is_unspecified()\n // 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)\n || matches!(v4.octets(), [100, 64..=127, ..])\n // 169.254.169.254 — cloud metadata (AWS/GCP/Azure)\n || *ip == std::net::IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))\n // 198.18.0.0/15 — IETF benchmark testing\n || matches!(v4.octets(), [198, 18..=19, ..])\n // 240.0.0.0/4 — reserved (former Class E)\n || v4.octets()[0] >= 240\n }\n std::net::IpAddr::V6(v6) => {\n // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4\n // to prevent bypass via ::ffff:127.0.0.1 etc.\n if v6.is_unspecified()\n || matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])\n {\n return true;\n }\n if let Some(v4) = v6.to_ipv4_mapped() {\n return is_restricted_ip(&std::net::IpAddr::V4(v4));\n }\n v6.is_loopback()\n || v6.is_multicast()\n || matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7\n || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10\n }\n }\n}\n\n/// Strip `\n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[test]\n fn rejects_private_localhost_literal() {\n assert!(is_restricted_ip(&\"127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_private_rfc1918() {\n assert!(is_restricted_ip(&\"10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"172.16.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"192.168.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cloud_metadata() {\n assert!(is_restricted_ip(&\"169.254.169.254\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_link_local() {\n assert!(is_restricted_ip(&\"169.254.1.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_cgnat() {\n assert!(is_restricted_ip(&\"100.64.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.63.0.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"100.128.0.1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv6_ula() {\n assert!(is_restricted_ip(&\"fc00::1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"fd12:3456::1\".parse().unwrap()));\n }\n\n #[test]\n fn rejects_ipv4_mapped_ipv6() {\n // ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass\n assert!(is_restricted_ip(&\"::ffff:127.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:10.0.0.1\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:169.254.169.254\".parse().unwrap()));\n assert!(is_restricted_ip(&\"::ffff:192.168.1.1\".parse().unwrap()));\n // :: (unspecified)\n assert!(is_restricted_ip(&\"::\".parse().unwrap()));\n }\n\n #[test]\n fn allows_public_ips() {\n assert!(!is_restricted_ip(&\"8.8.8.8\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"1.1.1.1\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"93.184.216.34\".parse().unwrap()));\n assert!(!is_restricted_ip(&\"2606:4700::1\".parse().unwrap()));\n }\n\n #[tokio::test]\n async fn rejects_localhost_hostname() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"http://localhost:8080/admin\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err}\").contains(\"localhost\"));\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n}\n"}, "f82f162e7fc49cbfaf08745f97b5a63cf6f4f583": {"t": 1785756843.107842, "n": 12642, "h": "999f7fc8c99422bb", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse crate::network_policy::{Decision, host_from_url};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n // Per-domain network policy gate (#135). If no policy is attached\n // (e.g. ad-hoc tests), behavior is permissive — match pre-v0.7.0.\n if let Some(decider) = context.network_policy.as_ref()\n && let Some(host) = host_from_url(&url)\n {\n match decider.evaluate(&host, \"fetch_url\") {\n Decision::Allow => {}\n Decision::Deny => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' blocked by network policy\"\n )));\n }\n Decision::Prompt => {\n return Err(ToolError::permission_denied(format!(\n \"network call to '{host}' requires approval; \\\n re-run after `/network allow {host}` or set network.default = \\\"allow\\\" in config\"\n )));\n }\n }\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n\n let client = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))\n .build()\n .map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(&url)\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\"` / `\n \n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn network_policy_denies_blocked_host() {\n use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};\n let policy = NetworkPolicy {\n default: Decision::Deny.into(),\n allow: vec![\"api.deepseek.com\".to_string()],\n deny: vec![],\n audit: false,\n };\n let decider = NetworkPolicyDecider::new(policy, None);\n let ctx = ToolContext::new(PathBuf::from(\".\")).with_network_policy(decider);\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"https://example.com/foo\"}), &ctx)\n .await;\n let err = res.expect_err(\"blocked host should fail\");\n assert!(format!(\"{err}\").contains(\"blocked\"));\n }\n}\n"}, "7f2c382343f0319a78cfbe4897459a22fd4d8649": {"t": 1785756844.635762, "n": 10866, "h": "075f0d9fdb191cc1", "c": "//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user\n//! already knows the URL — a known repo, a blog post, a spec page — and\n//! search is overkill or actively unhelpful.\n//!\n//! Returns a structured `{url, status, content_type, content, truncated}`\n//! payload. HTML responses are stripped to readable text by default\n//! (`format = \"markdown\"`); pass `format = \"raw\"` to keep the bytes intact\n//! when the model wants to do its own parsing.\n\nuse super::spec::{\n ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,\n};\nuse async_trait::async_trait;\nuse regex::Regex;\nuse serde::Serialize;\nuse serde_json::{Value, json};\nuse std::sync::OnceLock;\nuse std::time::Duration;\n\nconst DEFAULT_MAX_BYTES: u64 = 1_000_000;\nconst HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;\nconst DEFAULT_TIMEOUT_MS: u64 = 15_000;\nconst HARD_MAX_TIMEOUT_MS: u64 = 60_000;\nconst MAX_REDIRECTS: usize = 5;\nconst USER_AGENT: &str =\n \"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)\";\n\nstatic SCRIPT_RE: OnceLock = OnceLock::new();\nstatic STYLE_RE: OnceLock = OnceLock::new();\nstatic TAG_RE: OnceLock = OnceLock::new();\nstatic WHITESPACE_RE: OnceLock = OnceLock::new();\n\nfn script_re() -> &'static Regex {\n SCRIPT_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"script re\"))\n}\nfn style_re() -> &'static Regex {\n STYLE_RE.get_or_init(|| Regex::new(r\"(?is)]*>.*?\").expect(\"style re\"))\n}\nfn tag_re() -> &'static Regex {\n TAG_RE.get_or_init(|| Regex::new(r\"<[^>]+>\").expect(\"tag re\"))\n}\nfn whitespace_re() -> &'static Regex {\n WHITESPACE_RE.get_or_init(|| Regex::new(r\"\\s+\").expect(\"ws re\"))\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum Format {\n Text,\n Markdown,\n Raw,\n}\n\nimpl Format {\n fn parse(value: Option<&str>) -> Result {\n match value\n .unwrap_or(\"markdown\")\n .trim()\n .to_ascii_lowercase()\n .as_str()\n {\n \"text\" | \"txt\" | \"plain\" => Ok(Self::Text),\n \"markdown\" | \"md\" => Ok(Self::Markdown),\n \"raw\" | \"html\" | \"bytes\" => Ok(Self::Raw),\n other => Err(ToolError::invalid_input(format!(\n \"unknown format `{other}` (allowed: text, markdown, raw)\"\n ))),\n }\n }\n}\n\n#[derive(Debug, Serialize)]\nstruct FetchResponse {\n url: String,\n status: u16,\n content_type: String,\n content: String,\n truncated: bool,\n}\n\npub struct FetchUrlTool;\n\n#[async_trait]\nimpl ToolSpec for FetchUrlTool {\n fn name(&self) -> &'static str {\n \"fetch_url\"\n }\n\n fn description(&self) -> &'static str {\n \"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages.\"\n }\n\n fn input_schema(&self) -> Value {\n json!({\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\",\n \"description\": \"Absolute HTTP/HTTPS URL to fetch.\"\n },\n \"format\": {\n \"type\": \"string\",\n \"enum\": [\"text\", \"markdown\", \"raw\"],\n \"description\": \"Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is.\"\n },\n \"max_bytes\": {\n \"type\": \"integer\",\n \"description\": \"Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760).\"\n },\n \"timeout_ms\": {\n \"type\": \"integer\",\n \"description\": \"Request timeout in milliseconds (default 15,000; max 60,000).\"\n }\n },\n \"required\": [\"url\"]\n })\n }\n\n fn capabilities(&self) -> Vec {\n vec![ToolCapability::ReadOnly, ToolCapability::Network]\n }\n\n fn approval_requirement(&self) -> ApprovalRequirement {\n ApprovalRequirement::Auto\n }\n\n async fn execute(&self, input: Value, _context: &ToolContext) -> Result {\n let url = input\n .get(\"url\")\n .and_then(Value::as_str)\n .ok_or_else(|| ToolError::invalid_input(\"`url` is required\"))?\n .trim()\n .to_string();\n\n if url.is_empty() {\n return Err(ToolError::invalid_input(\"`url` cannot be empty\"));\n }\n let scheme_ok = url.starts_with(\"http://\") || url.starts_with(\"https://\");\n if !scheme_ok {\n return Err(ToolError::invalid_input(\n \"only http:// and https:// URLs are supported\",\n ));\n }\n\n let format = Format::parse(input.get(\"format\").and_then(Value::as_str))?;\n let max_bytes = optional_u64(&input, \"max_bytes\", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);\n let timeout_ms =\n optional_u64(&input, \"timeout_ms\", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);\n\n let client = reqwest::Client::builder()\n .timeout(Duration::from_millis(timeout_ms))\n .user_agent(USER_AGENT)\n .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))\n .build()\n .map_err(|e| {\n ToolError::execution_failed(format!(\"failed to build HTTP client: {e}\"))\n })?;\n\n let resp = client\n .get(&url)\n .header(\"Accept\", \"text/html,text/plain,application/json,*/*;q=0.5\")\n .header(\"Accept-Language\", \"en-US,en;q=0.5\")\n .send()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"request failed: {e}\")))?;\n\n let final_url = resp.url().to_string();\n let status = resp.status();\n let content_type = resp\n .headers()\n .get(reqwest::header::CONTENT_TYPE)\n .and_then(|v| v.to_str().ok())\n .unwrap_or(\"application/octet-stream\")\n .to_string();\n\n let bytes = resp\n .bytes()\n .await\n .map_err(|e| ToolError::execution_failed(format!(\"failed to read body: {e}\")))?;\n let total_bytes = bytes.len() as u64;\n let truncated = total_bytes > max_bytes;\n let usable = if truncated {\n &bytes[..max_bytes as usize]\n } else {\n &bytes[..]\n };\n\n let body_text = String::from_utf8_lossy(usable).to_string();\n let processed = match format {\n Format::Raw => body_text,\n Format::Text | Format::Markdown => {\n if content_type.contains(\"text/html\") || body_text.contains(\"` / `\n \n \n \n

Hello & welcome

\n

This is important.

\n \n \n \"#;\n let text = html_to_text(html);\n assert!(text.contains(\"Hello & welcome\"));\n assert!(text.contains(\"This is important\"));\n assert!(!text.contains(\"alert\"));\n assert!(!text.contains(\"color: red\"));\n }\n\n #[test]\n fn format_parse_accepts_aliases_and_rejects_unknown() {\n assert_eq!(Format::parse(Some(\"markdown\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"MD\")).unwrap(), Format::Markdown);\n assert_eq!(Format::parse(Some(\"text\")).unwrap(), Format::Text);\n assert_eq!(Format::parse(Some(\"raw\")).unwrap(), Format::Raw);\n assert_eq!(Format::parse(None).unwrap(), Format::Markdown);\n assert!(Format::parse(Some(\"yaml\")).is_err());\n }\n\n #[tokio::test]\n async fn rejects_non_http_schemes() {\n let tool = FetchUrlTool;\n let res = tool\n .execute(json!({\"url\": \"file:///etc/passwd\"}), &ctx())\n .await;\n let err = res.unwrap_err();\n assert!(format!(\"{err:?}\").contains(\"http\"));\n }\n\n #[tokio::test]\n async fn rejects_empty_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({\"url\": \" \"}), &ctx()).await;\n assert!(res.is_err());\n }\n\n #[tokio::test]\n async fn rejects_missing_url() {\n let tool = FetchUrlTool;\n let res = tool.execute(json!({}), &ctx()).await;\n assert!(res.is_err());\n }\n}\n"}}, "Dockerfile": {"050bc6d45b05cf827fd81afdde0b196c67071251": {"t": 1785746998.5732784, "n": 4075, "h": "9617ac3ae0269100", "c": "# syntax=docker/dockerfile:1\n# CodeWhale multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.deepseek codewhale\n#\n# The image ships the canonical binaries (`codewhale`, `codewhale-tui`) plus\n# the legacy `deepseek` / `deepseek-tui` shims in a minimal runtime layer.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY codewhale\n# Or mount an env file:\n# docker run --rm -it --env-file .env codewhale\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n -p codewhale-cli -p codewhale-tui \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/codewhale /out/ \\\n && cp target/$(cat /rust-target)/release/codewhale-tui /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 codewhale \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \\\n && install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.deepseek\nUSER codewhale\nWORKDIR /home/codewhale\n\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"codewhale\"]\nCMD []\n"}, "5fa24733e9a1d11315506309b8edb0fdf2b94f04": {"t": 1785756829.3586295, "n": 4075, "h": "9617ac3ae0269100", "c": "# syntax=docker/dockerfile:1\n# CodeWhale multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.deepseek codewhale\n#\n# The image ships the canonical binaries (`codewhale`, `codewhale-tui`) plus\n# the legacy `deepseek` / `deepseek-tui` shims in a minimal runtime layer.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY codewhale\n# Or mount an env file:\n# docker run --rm -it --env-file .env codewhale\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n -p codewhale-cli -p codewhale-tui \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/codewhale /out/ \\\n && cp target/$(cat /rust-target)/release/codewhale-tui /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 codewhale \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \\\n && install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.deepseek\nUSER codewhale\nWORKDIR /home/codewhale\n\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"codewhale\"]\nCMD []\n"}, "ddaabbfed296a54a1efb3b16f6ac250e71b9d9b1": {"t": 1785756830.5564377, "n": 4075, "h": "1cc62a7b686a941f", "c": "# syntax=docker/dockerfile:1\n# Codewhale multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.deepseek codewhale\n#\n# The image ships the canonical binaries (`codewhale`, `codewhale-tui`) plus\n# the legacy `deepseek` / `deepseek-tui` shims in a minimal runtime layer.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY codewhale\n# Or mount an env file:\n# docker run --rm -it --env-file .env codewhale\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n -p codewhale-cli -p codewhale-tui \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/codewhale /out/ \\\n && cp target/$(cat /rust-target)/release/codewhale-tui /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 codewhale \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \\\n && install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.deepseek\nUSER codewhale\nWORKDIR /home/codewhale\n\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale\nCOPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=codewhale:codewhale /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"codewhale\"]\nCMD []\n"}, "373fbd95a0316c3e9b7222560c7bdba54bab4767": {"t": 1785756831.7826853, "n": 3754, "h": "4557a74e2c0feec1", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v deepseek-tui-home:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY deepseek-tui\n# Or mount an env file:\n# docker run --rm -it --env-file .env deepseek-tui\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=deepseek-tui-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 deepseek \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek \\\n && install -d -m 0700 -o deepseek -g deepseek /home/deepseek/.deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "360438f0c9a2e400031a7efd558edd68cf03709e": {"t": 1785756833.2256098, "n": 3677, "h": "74654633e12c2060", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v deepseek-tui-home:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY deepseek-tui\n# Or mount an env file:\n# docker run --rm -it --env-file .env deepseek-tui\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=deepseek-tui-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 deepseek \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "10c6901b640a47acb406e152762c5e9dfc9492f4": {"t": 1785756834.5633929, "n": 3671, "h": "3ffe7c81addffcee", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY deepseek-tui\n# Or mount an env file:\n# docker run --rm -it --env-file .env deepseek-tui\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\nARG DEEPSEEK_BUILD_SHA\n\nENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \\\n CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \\\n PKG_CONFIG_ALLOW_CROSS=1 \\\n PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \\\n DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}\n\nRUN if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n dpkg --add-architecture arm64; \\\n fi \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && if [ \"${TARGETARCH}\" = \"arm64\" ] && [ \"${BUILDPLATFORM}\" != \"${TARGETPLATFORM}\" ]; then \\\n apt-get install -y --no-install-recommends \\\n gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \\\n fi \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=deepseek-tui-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 deepseek \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "8f181c80f840129ba2f6b3ea362c99411bd91ea6": {"t": 1785756835.9414084, "n": 2947, "h": "058b848275b55f39", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY deepseek-tui\n# Or mount an env file:\n# docker run --rm -it --env-file .env deepseek-tui\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG TARGETARCH\nARG BUILDPLATFORM\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,id=deepseek-tui-target-${TARGETARCH},target=/build/target,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \\\n --mount=type=cache,id=deepseek-tui-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 deepseek \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "6e2b854fdb331504e6808ebc97f8c53d22ca3c0f": {"t": 1785756837.2960696, "n": 2712, "h": "d056147d5b751945", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n#\n# API keys MUST be passed at runtime (never baked into the image):\n# docker run --rm -it -e DEEPSEEK_API_KEY deepseek-tui\n# Or mount an env file:\n# docker run --rm -it --env-file .env deepseek-tui\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG BUILDPLATFORM\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,target=/build/target \\\n --mount=type=cache,target=/usr/local/cargo/registry \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user with explicit UID/GID for filesystem ownership clarity.\nRUN groupadd --gid 1000 deepseek \\\n && useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "0fded51824b60d3bfdb41692d3dc7824b6451e05": {"t": 1785756838.599272, "n": 2444, "h": "8720f97ef2efbdfe", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n\nARG RUST_VERSION=1.88\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG BUILDPLATFORM\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,target=/build/target \\\n --mount=type=cache,target=/usr/local/cargo/registry \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user.\nRUN useradd --create-home --shell /bin/bash deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENV DEEPSEEK_API_KEY=\"\"\nENV DEEPSEEK_NO_COLOR=\"\"\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}, "6ff4db5ba005ac58b4295df6f870c05f92c649cb": {"t": 1785756839.7723167, "n": 2444, "h": "8c3936b67f18cce8", "c": "# syntax=docker/dockerfile:1\n# DeepSeek-TUI multi-arch Docker image (#501)\n#\n# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .\n# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui\n#\n# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)\n# in a minimal runtime layer. No MCP servers or heavy toolchains are included\n# — keep it slim.\n\nARG RUST_VERSION=1.85\n\n# ── Stage 1: Build ────────────────────────────────────────────────────\nFROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder\nARG TARGETPLATFORM\nARG BUILDPLATFORM\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n pkg-config libdbus-1-dev \\\n && rm -rf /var/lib/apt/lists/*\n\n# Translate Docker platform into Rust target triple.\n# linux/amd64 → x86_64-unknown-linux-gnu\n# linux/arm64 → aarch64-unknown-linux-gnu\nRUN case \"${TARGETPLATFORM}\" in \\\n linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \\\n linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \\\n *) echo \"Unsupported platform: ${TARGETPLATFORM}\" >&2; exit 1 ;; \\\n esac\n\nRUN rustup target add \"$(cat /rust-target)\"\n\nWORKDIR /build\nCOPY . .\n\n# Build both binaries for the target platform. --locked ensures\n# reproducible builds from the committed lockfile.\nRUN --mount=type=cache,target=/build/target \\\n --mount=type=cache,target=/usr/local/cargo/registry \\\n cargo build --release --locked --target \"$(cat /rust-target)\" \\\n && mkdir -p /out \\\n && cp target/$(cat /rust-target)/release/deepseek /out/ \\\n && cp target/$(cat /rust-target)/release/deepseek-tui /out/\n\n# ── Stage 2: Runtime ──────────────────────────────────────────────────\nFROM debian:bookworm-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n ca-certificates \\\n libdbus-1-3 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Non-root user.\nRUN useradd --create-home --shell /bin/bash deepseek\nUSER deepseek\nWORKDIR /home/deepseek\n\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek\nCOPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui\n\n# The dispatcher expects to find its companion binary next to it.\n# Both are in /usr/local/bin — no further path setup needed.\n\nENV DEEPSEEK_API_KEY=\"\"\nENV DEEPSEEK_NO_COLOR=\"\"\n\nENTRYPOINT [\"deepseek\"]\nCMD []\n"}}}}